From 3adb8bfa55df1a49649b4144f4bf85164e4a23ce Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:36:13 +0100 Subject: [PATCH 01/16] Add a registry of global settings pages Settings::Pages records the administration pages global settings are shown on, so the settings search can find a setting and link to it. Each page references its admin menu item and lists its settings, optionally as a tab of the menu item, served by another controller or behind conditions. Labels and captions default to the setting's translations and can be given per setting. Engines can register pages and extend existing ones. --- config/constants/settings/pages.rb | 208 ++++++++++++++++++ lib/open_project/plugins/acts_as_op_engine.rb | 1 + spec/constants/settings/pages_spec.rb | 153 +++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 config/constants/settings/pages.rb create mode 100644 spec/constants/settings/pages_spec.rb diff --git a/config/constants/settings/pages.rb b/config/constants/settings/pages.rb new file mode 100644 index 000000000000..d5a5ed77b387 --- /dev/null +++ b/config/constants/settings/pages.rb @@ -0,0 +1,208 @@ +# 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 + # Registry of the administration pages global settings are shown on, used + # by the settings search to find a setting and link to it. + # + # Each page is linked to an entry of the admin menu and lists the settings + # shown on it. Pages sharing a menu entry and having a `label` are listed as + # tabs of that entry, selected by their `tab` parameter or served by another + # controller (`url:`). + # + # Settings are listed with the "setting_" translation unless a `label` + # is given, and described by a `caption` or the "setting__caption(_html)" + # translation. Both accept a translation key, a string or a lambda evaluated + # in the view context. + # + # Settings changed through something other than a "settings[]" form + # field on their page (e.g. a toggle or a dialog) are registered with + # `form_field: false`. The element representing them needs a + # `data-setting-name` attribute for the settings search to highlight it. + # + # @example + # Settings::Pages.draw do + # page :general, menu_item: :settings_general do + # setting :app_title + # + # section :welcome, if: -> { Setting.welcome_on_homescreen? } do + # setting :welcome_title + # end + # end + # end + # + # Settings::Pages.extend_page(:general) do + # setting :my_plugin_setting + # end + module Pages + class Entry + attr_reader :name, :condition + + def initialize(name, label: nil, caption: nil, form_field: true, **options) + @name = name.to_sym + @label = label + @caption = caption + @form_field = form_field + @condition = options[:if] + end + + def form_field? + @form_field + end + + def definition + Settings::Definition[name] || raise(ArgumentError, "No setting definition for #{name.inspect}") + end + + def visible? + condition.nil? || condition.call + end + + def label(view_context) + resolve_text(@label, view_context) || I18n.t("setting_#{name}") + end + + def caption(view_context) + resolve_text(@caption, view_context) || + view_context.t("setting_#{name}_caption_html", default: nil) || + I18n.t("setting_#{name}_caption", default: nil) + end + + private + + def resolve_text(text, view_context) + case text + when Proc then view_context.instance_exec(&text) + when Symbol then view_context.t(text) + else text + end + end + end + + class Section + attr_reader :key, :entries, :condition + + def initialize(key, **options) + @key = key + @condition = options[:if] + @entries = [] + end + + def setting(name, **) + entries << Entry.new(name, **) + end + + def visible? + condition.nil? || condition.call + end + + def visible_entries + visible? ? entries.select(&:visible?) : [] + end + end + + class Page + attr_reader :key, :menu_item, :tab, :label, :sections + + def initialize(key, menu_item:, tab: nil, label: nil, url: nil, **options) + @key = key + @menu_item = menu_item + @tab = tab + @label = label + @url = url + @condition = options[:if] + @sections = [] + end + + def visible? + @condition.nil? || @condition.call + end + + def setting(name, **) + @sections << Section.new(nil) if @sections.empty? || @sections.last.key + @sections.last.setting(name, **) + end + + def section(key, **, &) + section = @sections.find { it.key == key } || Section.new(key, **).tap { @sections << it } + section.instance_exec(&) + end + + def entries + sections.flat_map(&:entries) + end + + def url(menu = Pages.admin_menu) + base = @url || menu_node(menu).url + tab ? base.merge(tab:) : base + end + + def menu_node(menu = Pages.admin_menu) + menu.find { it.name == menu_item } || + raise(ArgumentError, "No admin menu item #{menu_item.inspect} for settings page #{key.inspect}") + end + end + + class << self + def draw(&) + instance_exec(&) + end + + def page(key, **, &) + key = key.to_sym + raise ArgumentError, "Settings page #{key.inspect} is already registered" if registry.key?(key) + + registry[key] = Page.new(key, **).tap { it.instance_exec(&) if block_given? } + end + + def extend_page(key, &) + fetch(key).instance_exec(&) + end + + def fetch(key) + registry.fetch(key.to_sym) + end + + def all + registry.values + end + + def admin_menu + Redmine::MenuManager.items(:admin_menu) + end + + private + + def registry + @registry ||= {} + end + end + end +end diff --git a/lib/open_project/plugins/acts_as_op_engine.rb b/lib/open_project/plugins/acts_as_op_engine.rb index 8ba37eb38a4e..c64221800b7d 100644 --- a/lib/open_project/plugins/acts_as_op_engine.rb +++ b/lib/open_project/plugins/acts_as_op_engine.rb @@ -32,6 +32,7 @@ require_relative "../../../config/constants/open_project/activity" require_relative "../../../config/constants/views" require_relative "../../../config/constants/settings/definition" +require_relative "../../../config/constants/settings/pages" module OpenProject::Plugins module ActsAsOpEngine diff --git a/spec/constants/settings/pages_spec.rb b/spec/constants/settings/pages_spec.rb new file mode 100644 index 000000000000..01d982e74bd6 --- /dev/null +++ b/spec/constants/settings/pages_spec.rb @@ -0,0 +1,153 @@ +# 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 Settings::Pages do + let(:view_context) { ApplicationController.new.view_context } + + describe Settings::Pages::Entry do + describe "#visible?" do + it "is true without a condition" do + expect(described_class.new(:app_title)).to be_visible + end + + it "evaluates the condition" do + expect(described_class.new(:app_title, if: -> { false })).not_to be_visible + end + end + + describe "#label" do + it "defaults to the setting's translation" do + expect(described_class.new(:app_title).label(view_context)).to eq I18n.t(:setting_app_title) + end + + it "translates a given key" do + expect(described_class.new(:app_title, label: :label_administration).label(view_context)) + .to eq I18n.t(:label_administration) + end + + it "evaluates a given lambda in the view context" do + expect(described_class.new(:app_title, label: -> { t(:label_administration) }).label(view_context)) + .to eq I18n.t(:label_administration) + end + end + + describe "#caption" do + it "defaults to the setting's caption translation" do + expect(described_class.new(:login_required).caption(view_context)).to eq I18n.t(:setting_login_required_caption) + end + + it "is nil without a caption" do + expect(described_class.new(:app_title).caption(view_context)).to be_nil + end + end + end + + describe Settings::Pages::Page do + subject(:settings_page) do + described_class.new(:spec, menu_item: :settings_general).tap do |page| + page.instance_exec do + setting :app_title + + section :welcome, if: -> { false } do + setting :welcome_title + end + + setting :feeds_enabled + end + end + end + + it "keeps settings in declaration order, starting a new unnamed section after a named one" do + expect(settings_page.sections.map(&:key)).to eq [nil, :welcome, nil] + expect(settings_page.entries.map(&:name)).to eq %i[app_title welcome_title feeds_enabled] + end + + it "omits the entries of hidden sections" do + expect(settings_page.sections.flat_map(&:visible_entries).map(&:name)).to eq %i[app_title feeds_enabled] + end + + it "links to its menu item" do + expect(settings_page.url).to eq settings_page.menu_node.url + end + + it "links to its tab of the menu item" do + tab_page = described_class.new(:spec, menu_item: :settings_general, tab: "spec") + + expect(tab_page.url).to eq settings_page.menu_node.url.merge(tab: "spec") + end + + it "prefers a given url" do + url = { controller: "/admin/settings/virus_scanning_settings", action: :show } + + expect(described_class.new(:spec, menu_item: :attachments, url:).url).to eq url + end + end + + describe "registry" do + before do + allow(described_class).to receive(:registry).and_return({}) + end + + it "registers pages and allows extending them" do + described_class.draw do + page :spec, menu_item: :settings_general do + setting :app_title + end + end + + described_class.extend_page(:spec) do + setting :feeds_enabled + end + + expect(described_class.fetch(:spec).entries.map(&:name)).to eq %i[app_title feeds_enabled] + end + + it "refuses to register a page twice" do + described_class.page(:spec, menu_item: :settings_general) + + expect { described_class.page(:spec, menu_item: :settings_general) }.to raise_error(ArgumentError) + end + end + + describe "registered pages" do + it "only list defined settings", :aggregate_failures do + described_class.all.flat_map(&:entries).each do |entry| + expect { entry.definition }.not_to raise_error, "for #{entry.name}" + end + end + + it "belong to existing admin menu items", :aggregate_failures do + described_class.all.each do |settings_page| + expect { settings_page.menu_node }.not_to raise_error, "for #{settings_page.key}" + end + end + end +end From 10b745088b27fd8939a8a19fbf8f10b4e8f1bb95 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:37:58 +0100 Subject: [PATCH 02/16] Register the hand-written global settings pages Register all existing settings pages in Settings::Pages, including tabbed pages and the costs module's pages, so every global setting has a known place in the admin menu hierarchy. Settings whose page shows a label or caption other than their default translation get it in the registry. A request spec renders each registered page and fails when its settings fields and the registry drift apart. --- config/initializers/settings_pages.rb | 309 ++++++++++++++++++ config/locales/en.yml | 1 + modules/costs/lib/costs/engine.rb | 23 ++ .../admin/settings/registered_pages_spec.rb | 61 ++++ 4 files changed, 394 insertions(+) create mode 100644 config/initializers/settings_pages.rb create mode 100644 spec/requests/admin/settings/registered_pages_spec.rb diff --git a/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb new file mode 100644 index 000000000000..d94b1fd3a7a0 --- /dev/null +++ b/config/initializers/settings_pages.rb @@ -0,0 +1,309 @@ +# 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. +#++ + +Settings::Pages.draw do + page :general, menu_item: :settings_general do + setting :app_title + setting :organization_name + setting :per_page_options, + caption: -> { safe_join([t(:text_comma_separated), t(:text_notice_too_many_values_are_inperformant)], tag.br) } + setting :activity_days_default + setting :host_name, + caption: -> { "#{t(:label_example)}: #{request.host_with_port}" } + setting :cache_formatted_text + setting :allowed_link_protocols, + caption: -> { + t(:setting_allowed_link_protocols_text_html, + tel_code: tag.code("tel"), + element_code: tag.code("element"), + http_code: tag.code("http"), + https_code: tag.code("https"), + mailto_code: tag.code("mailto")) + } + setting :feeds_enabled + setting :feeds_limit + setting :file_max_size_displayed + setting :diff_max_lines_displayed + setting :security_badge_displayed, + caption: -> { + t(:text_notice_security_badge_displayed_html, + information_panel_label: t(:label_information), + more_info_url: OpenProject::Static::Links.url_for(:security_badge_documentation), + information_panel_path: info_admin_index_path) + }, + if: -> { OpenProject::Configuration.security_badge_displayed? } + setting :welcome_title + setting :welcome_text + setting :welcome_on_homescreen + end + + page :external_links, menu_item: :settings_external_links do + setting :capture_external_links, + caption: :setting_capture_external_links_text + setting :capture_external_links_require_login, + caption: :setting_capture_external_links_require_login_text + end + + page :languages, menu_item: :settings_languages do + setting :available_languages + end + + page :exports, menu_item: :settings_exports do + setting :work_packages_projects_export_limit, + caption: :setting_work_packages_projects_export_limit_text + setting :csv_escape_formulas, + caption: :setting_csv_escape_formulas_text + end + + page :repositories, menu_item: :settings_repositories do + setting :autofetch_changesets, + caption: :"repositories.autofetch_information" + setting :repository_storage_cache_minutes, + caption: :"repositories.storage.update_timeout" + setting :sys_api_enabled, + caption: :setting_sys_api_description + setting :sys_api_key + setting :enabled_scm + setting :repositories_automatic_managed_vendor, + caption: :"repositories.settings.automatic_managed_repos_text" + setting :repositories_encodings, + caption: :text_comma_separated + setting :repository_log_display_limit + setting :repository_truncate_at + setting :repository_checkout_data, label: :setting_repository_checkout_display + setting :commit_ref_keywords, + caption: :text_comma_separated + setting :commit_fix_keywords, + label: -> { t(%i[setting_commit_fix_keywords label_keyword_plural]).join(": ") }, + caption: :text_comma_separated + setting :commit_fix_status_id, label: -> { I18n.t(%i[setting_commit_fix_keywords label_applied_status]).join(": ") } + setting :commit_logtime_enabled + setting :commit_logtime_activity_id + end + + page :users, menu_item: :user_settings do + setting :default_language + setting :user_default_timezone, + caption: :tooltip_user_default_timezone + setting :default_auto_hide_popups, label: :"activerecord.attributes.user_preference.auto_hide_popups" + setting :user_format + setting :user_can_change_email + setting :users_deletable_by_admins + setting :users_deletable_by_self + setting :consent_required + setting :consent_info + setting :consent_decline_mail, + caption: :"consent.contact_mail_instructions" + end + + page :work_packages_general, menu_item: :work_packages_general do + setting :cross_project_work_package_relations + setting :display_subprojects_work_packages + setting :work_package_startdate_is_adddate + setting :work_package_list_default_highlighting_mode + setting :work_package_list_default_highlighted_attributes + end + + page :work_packages_identifier, menu_item: :work_packages_identifier do + setting :work_packages_identifier, label: :"settings.work_packages.work_package_identifier" + end + + page :progress_tracking, menu_item: :progress_tracking do + setting :work_package_done_ratio + setting :total_percent_complete_mode + setting :percent_complete_on_status_closed + end + + page :new_project, menu_item: :new_project_settings, tab: "settings", label: :label_setting_plural do + setting :default_projects_public + setting :default_projects_wiki + setting :default_projects_modules + setting :new_project_user_role_id + end + + page :new_project_notifications, + menu_item: :new_project_settings, + tab: "notifications", + label: :label_notification_center_plural do + setting :new_project_send_confirmation_email + setting :new_project_notification_text + end + + page :working_days_and_hours, menu_item: :working_days_and_hours do + setting :hours_per_day + setting :duration_format + setting :working_days + end + + page :date_format, menu_item: :date_format do + setting :date_format + setting :time_format + setting :start_of_week + setting :first_week_of_year, + caption: -> { + t("settings.date_format.first_week_of_year_text_html", + link: OpenProject::Static::Links.url_for(:date_format_settings_documentation)) + } + end + + page :icalendar, menu_item: :icalendar do + setting :ical_enabled + end + + page :aggregation, menu_item: :notification_settings do + setting :journal_aggregation_time_minutes, + caption: -> { + link_translate("admin.journal_aggregation.caption_with_maximum", + i18n_args: { max: Settings::Definition[:journal_aggregation_time_minutes].allowed.max }, + links: { webhook_link: admin_outgoing_webhooks_path }) + } + end + + page :mail_notifications, menu_item: :mail_notifications do + section :mails, if: -> { ActionMailer::Base.perform_deliveries } do + setting :mail_from + setting :bcc_recipients + setting :plain_text_mail + setting :emails_salutation + setting :emails_header + setting :emails_footer + end + + section :delivery, + if: -> { OpenProject::Configuration["email_delivery_configuration"] != "legacy" } do + setting :email_delivery_method + setting :smtp_address + setting :smtp_port + setting :smtp_domain + setting :smtp_authentication + setting :smtp_user_name + setting :smtp_password + setting :smtp_enable_starttls_auto + setting :smtp_ssl + setting :sendmail_location + setting :sendmail_arguments + end + end + + page :incoming_mails, menu_item: :incoming_mails do + setting :mail_handler_body_delimiters, + caption: :text_line_separated + setting :mail_handler_body_delimiter_regex, + caption: :text_regexp_multiline + setting :mail_handler_ignore_filenames, + caption: :"incoming_mails.ignore_filenames" + end + + page :api, menu_item: :api do + setting :api_tokens_enabled, + caption: :setting_api_tokens_enabled_caption + setting :apiv3_max_page_size, + caption: :setting_apiv3_max_page_size_instructions + setting :apiv3_write_readonly_attributes, + caption: :setting_apiv3_write_readonly_attributes_instructions + setting :apiv3_docs_enabled, + caption: :setting_apiv3_docs_enabled_instructions_warning + setting :apiv3_cors_enabled + setting :apiv3_cors_origins, + caption: :text_line_separated + end + + page :authentication, + menu_item: :authentication_settings, + tab: "login", + label: :"settings.authentication.login" do + setting :autologin + setting :session_ttl_enabled + setting :session_ttl, + caption: :setting_session_ttl_hint + setting :log_requesting_user + setting :after_first_login_redirect_url, + caption: :setting_after_first_login_redirect_url_text_html + setting :after_login_default_redirect_url, + caption: -> { + t(:setting_after_login_default_redirect_url_example_html, example_code: content_tag(:code, "/my/page")) + } + end + + page :authentication_sso, + menu_item: :authentication_settings, + tab: "sso", + label: :"settings.authentication.sso" do + setting :omniauth_direct_login_provider + setting :oauth_allow_remapping_of_existing_users + setting :password_login + setting :password_login_bypass_principal_ids + end + + page :authentication_registration, + menu_item: :authentication_settings, + tab: "registration", + label: :"settings.authentication.registration" do + setting :login_required + setting :self_registration, + caption: :setting_self_registration_caption + setting :invitation_expiration_days + setting :registration_footer + end + + page :authentication_passwords, + menu_item: :authentication_settings, + tab: "passwords", + label: :"settings.passwords" do + setting :password_min_length + setting :password_active_rules + setting :password_days_valid, + caption: -> { t("text_hint_disable_with_0") } + setting :password_count_former_banned + setting :lost_password + setting :brute_force_block_after_failed_logins, + caption: -> { t("text_hint_disable_with_0") } + setting :brute_force_block_minutes, + caption: -> { t("text_hint_disable_with_0") } + end + + page :attachments, menu_item: :attachments, label: :"settings.general" do + setting :show_work_package_attachments, + caption: :"settings.attachments.show_work_package_attachments" + setting :attachment_max_size + setting :attachment_whitelist, + caption: -> { t("settings.attachments.whitelist_text_html", ext_example: "*.jpg", mime_example: "image/jpeg") } + end + + page :virus_scanning, + menu_item: :attachments, + label: :"settings.antivirus.title", + if: -> { Setting.antivirus_scan_available? }, + url: { controller: "/admin/settings/virus_scanning_settings", action: :show } do + setting :antivirus_scan_mode + setting :antivirus_scan_target, if: -> { Setting.antivirus_scan_mode != :disabled } + setting :antivirus_scan_action, if: -> { Setting.antivirus_scan_mode != :disabled } + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index f198dc9c8537..bd97a2b2536f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6572,6 +6572,7 @@ en: Protocols %{http_code}, %{https_code}, and %{mailto_code} are always allowed. setting_antivirus_scan_action: "Infected file action" setting_antivirus_scan_mode: "Scan mode" + setting_antivirus_scan_target: "ClamAV socket or host" setting_api_tokens_enabled: "Enable API tokens" setting_api_tokens_enabled_caption: > Decide whether users can create personal API tokens in their account settings. These tokens can be used to access the different diff --git a/modules/costs/lib/costs/engine.rb b/modules/costs/lib/costs/engine.rb index c81b004c4eee..94aa83a43f9f 100644 --- a/modules/costs/lib/costs/engine.rb +++ b/modules/costs/lib/costs/engine.rb @@ -193,6 +193,29 @@ class Engine < ::Rails::Engine ::Settings::Definition.add "time_entries_past_month_grace_days", default: 0, format: :integer, allowed: (0..) end + initializer "costs.settings_pages" do + ::Settings::Pages.draw do + page :time, menu_item: :costs_settings, label: :label_time do + setting :allow_tracking_start_and_end_times + setting :enforce_tracking_start_and_end_times + setting :time_entries_max_hours_per_entry + setting :time_entries_max_hours_per_day + setting :time_entries_prohibit_logging_on_non_working_days + setting :time_entries_limit_to_user_working_hours + setting :time_entries_prohibit_logging_for_past_months + setting :time_entries_past_month_grace_days + end + + page :costs, + menu_item: :costs_settings, + label: :label_costs, + url: { controller: "/admin/costs_settings", action: :show } do + setting :costs_currency + setting :costs_currency_format + end + end + end + activity_provider :time_entries, class_name: "Activities::TimeEntryActivityProvider", default: false replace_principal_references "CostEntry" => %i[logged_by_id user_id] diff --git a/spec/requests/admin/settings/registered_pages_spec.rb b/spec/requests/admin/settings/registered_pages_spec.rb new file mode 100644 index 000000000000..98cf36657686 --- /dev/null +++ b/spec/requests/admin/settings/registered_pages_spec.rb @@ -0,0 +1,61 @@ +# 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 "Registered settings pages", + :settings_reset, + type: :rails_request, + with_ee: %i[sso_auth_providers] do + shared_let(:admin) { create(:admin) } + + before { login_as(admin) } + + def rendered_setting_names + response + .parsed_body + .css("[name^='settings[']") + .map { it["name"][/\Asettings\[([^\]]+)\]/, 1].to_sym } + .select { Settings::Definition.exists?(it) } + .uniq + end + + shared_examples "registered settings pages" do + Settings::Pages.all.each do |settings_page| + it "lists exactly the settings rendered on the #{settings_page.key} page", :aggregate_failures do + get url_for(**settings_page.url, only_path: true) + + expect(response).to have_http_status(:ok) + expect(rendered_setting_names).to match_array(settings_page.sections.flat_map(&:visible_entries).map(&:name)) + end + end + end + + it_behaves_like "registered settings pages" +end From 5e952c9406c373d7b61be4a518e10ddb905cf061 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:37:58 +0100 Subject: [PATCH 03/16] Register settings changed outside of settings forms Some global settings are changed through toggles, dialogs or model forms instead of settings form fields: multiple target versions, LLM features, text transform actions and real-time collaboration. Register them with `form_field: false` and mark the element representing them with `data-setting-name`. The drift spec checks for these markers and renders all pages a second time with optional states enabled. --- .../setting_toggle_component.html.erb | 2 +- ...target_versions_section_component.html.erb | 8 ++++++- app/forms/llm_connections/connection_form.rb | 2 +- config/initializers/settings_pages.rb | 15 ++++++++++++ config/locales/en.yml | 3 +++ .../page_header_component.html.erb | 1 + .../settings_form_component.html.erb | 6 ++++- .../lib/open_project/documents/engine.rb | 17 ++++++++++++++ .../admin/settings/registered_pages_spec.rb | 23 +++++++++++++++++-- 9 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/components/admin/text_transform_actions/setting_toggle_component.html.erb b/app/components/admin/text_transform_actions/setting_toggle_component.html.erb index 98fcd23cb13a..b45f87ba6e53 100644 --- a/app/components/admin/text_transform_actions/setting_toggle_component.html.erb +++ b/app/components/admin/text_transform_actions/setting_toggle_component.html.erb @@ -37,7 +37,7 @@ See COPYRIGHT and LICENSE files for more details. status_label_position: :start, checked: enabled?, enabled: writable?, - data: { turbo: true }, + data: { turbo: true, setting_name: SETTING_NAME }, test_selector: "toggle-ai-text-transform-actions-enabled", mb: 3 ) diff --git a/app/components/work_packages/admin/settings/target_versions_section_component.html.erb b/app/components/work_packages/admin/settings/target_versions_section_component.html.erb index 42687ebd4d9d..8a6a8010d495 100644 --- a/app/components/work_packages/admin/settings/target_versions_section_component.html.erb +++ b/app/components/work_packages/admin/settings/target_versions_section_component.html.erb @@ -33,7 +33,13 @@ See COPYRIGHT and LICENSE files for more details. <% box.with_row(icon: :info) { scoped_t(:"planned_changes.single_value") } %> <% end %> - <%= render(WorkPackages::Admin::Settings::NoticeBoxComponent.new(heading: scoped_t(:"action_required.heading"), mt: 3)) do |box| %> + <%= render( + WorkPackages::Admin::Settings::NoticeBoxComponent.new( + heading: scoped_t(:"action_required.heading"), + mt: 3, + data: { setting_name: "work_package_multiple_versions" } + ) + ) do |box| %> <% if multiple_versions_writable? %> <% box.with_row(icon: :tools) { scoped_t(:"action_required.manual_conversion_hint") } %> <% else %> diff --git a/app/forms/llm_connections/connection_form.rb b/app/forms/llm_connections/connection_form.rb index 99330585ddbe..da75c18f88a5 100644 --- a/app/forms/llm_connections/connection_form.rb +++ b/app/forms/llm_connections/connection_form.rb @@ -35,7 +35,7 @@ class ConnectionForm < ApplicationForm label: LlmConnection.human_attribute_name(:llm_features_enabled), caption: I18n.t("admin.llm_connections.form.llm_features_enabled_caption"), disabled: read_only? || !Setting.llm_features_enabled_writable?, - data: { target_name: "llm_features_enabled", show_when_checked_target: "cause" } + data: { target_name: "llm_features_enabled", show_when_checked_target: "cause", setting_name: "llm_features_enabled" } ) f.fieldset_group( diff --git a/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb index d94b1fd3a7a0..e70d768d7b6b 100644 --- a/config/initializers/settings_pages.rb +++ b/config/initializers/settings_pages.rb @@ -135,6 +135,10 @@ setting :work_packages_identifier, label: :"settings.work_packages.work_package_identifier" end + page :versions_and_categories, menu_item: :versions_and_categories do + setting :work_package_multiple_versions, form_field: false, if: -> { !Setting.work_package_multiple_versions? } + end + page :progress_tracking, menu_item: :progress_tracking do setting :work_package_done_ratio setting :total_percent_complete_mode @@ -289,6 +293,17 @@ caption: -> { t("text_hint_disable_with_0") } end + page :llm_connection, menu_item: :llm_connection do + setting :llm_features_enabled, + form_field: false, + label: -> { LlmConnection.human_attribute_name(:llm_features_enabled) }, + caption: :"admin.llm_connections.form.llm_features_enabled_caption" + end + + page :text_transform_actions, menu_item: :text_transform_actions do + setting :ai_text_transform_actions_enabled, form_field: false + end + page :attachments, menu_item: :attachments, label: :"settings.general" do setting :show_work_package_attachments, caption: :"settings.attachments.show_work_package_attachments" diff --git a/config/locales/en.yml b/config/locales/en.yml index bd97a2b2536f..d7512c25ee12 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6724,6 +6724,9 @@ en: setting_project_gantt_query: "Project portfolio Gantt view" setting_project_gantt_query_text: "You can modify the query that is used to display Gantt chart from the project overview page." setting_protocol: "Protocol" + setting_real_time_text_collaboration_enabled: "Real-time collaboration" + setting_real_time_text_collaboration_enabled_caption: > + Allows multiple users to edit a document at the same time. Requires a working Hocuspocus server. setting_registration_footer: "Registration footer" setting_registration_footer_caption: "This text is displayed in the footer of the registration page. Use the HTML editor to format the text for each selected language." setting_repositories_automatic_managed_vendor: "Automatic repository vendor type" diff --git a/modules/documents/app/components/documents/admin/collaboration_settings/page_header_component.html.erb b/modules/documents/app/components/documents/admin/collaboration_settings/page_header_component.html.erb index 1590c198ad59..8f7c182810bc 100644 --- a/modules/documents/app/components/documents/admin/collaboration_settings/page_header_component.html.erb +++ b/modules/documents/app/components/documents/admin/collaboration_settings/page_header_component.html.erb @@ -48,6 +48,7 @@ See COPYRIGHT and LICENSE files for more details. content_arguments: { data: { controller: "async-dialog" } }, + data: { setting_name: "real_time_text_collaboration_enabled" }, href: delete_dialog_admin_settings_document_collaboration_settings_path ) { I18n.t(:button_disable) } end diff --git a/modules/documents/app/components/documents/admin/collaboration_settings/settings_form_component.html.erb b/modules/documents/app/components/documents/admin/collaboration_settings/settings_form_component.html.erb index 7bb8356866cc..0adf598896e2 100644 --- a/modules/documents/app/components/documents/admin/collaboration_settings/settings_form_component.html.erb +++ b/modules/documents/app/components/documents/admin/collaboration_settings/settings_form_component.html.erb @@ -60,7 +60,11 @@ See COPYRIGHT and LICENSE files for more details. end else render( - Primer::Beta::Blankslate.new(border: true, test_selector: "collaboration-settings-disabled-notice") + Primer::Beta::Blankslate.new( + border: true, + test_selector: "collaboration-settings-disabled-notice", + data: { setting_name: "real_time_text_collaboration_enabled" } + ) ) do |component| component.with_visual_icon(icon: :pencil) component.with_heading(tag: :h2).with_content(I18n.t("documents.admin.enable_text_collaboration.heading")) diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index fe807e947ffe..6cb606eab367 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -105,6 +105,23 @@ class Engine < ::Rails::Engine Redmine::Search.register :documents end + initializer "documents.settings_pages" do + ::Settings::Pages.draw do + page :document_collaboration, menu_item: :document_collaboration_settings do + setting :real_time_text_collaboration_enabled, form_field: false + + section :server, if: -> { Setting.real_time_text_collaboration_enabled? } do + setting :collaborative_editing_hocuspocus_url, + label: :"documents.admin.collaboration_settings.hocuspocus_server_url.label", + caption: :"documents.admin.collaboration_settings.hocuspocus_server_url.caption" + setting :collaborative_editing_hocuspocus_secret, + label: :"documents.admin.collaboration_settings.hocuspocus_server_secret.label", + caption: :"documents.admin.collaboration_settings.hocuspocus_server_secret.caption" + end + end + end + end + activity_provider :documents, class_name: "Activities::DocumentActivityProvider", default: false patches %i[Project] diff --git a/spec/requests/admin/settings/registered_pages_spec.rb b/spec/requests/admin/settings/registered_pages_spec.rb index 98cf36657686..b96442cf3fb5 100644 --- a/spec/requests/admin/settings/registered_pages_spec.rb +++ b/spec/requests/admin/settings/registered_pages_spec.rb @@ -32,7 +32,8 @@ RSpec.describe "Registered settings pages", :settings_reset, type: :rails_request, - with_ee: %i[sso_auth_providers] do + with_ee: %i[sso_auth_providers], + with_flag: { llm_connection: true, ai_text_transform_actions: true } do shared_let(:admin) { create(:admin) } before { login_as(admin) } @@ -46,16 +47,34 @@ def rendered_setting_names .uniq end + def marked_setting_names + response.parsed_body.css("[data-setting-name]").map { it["data-setting-name"].to_sym }.uniq + end + shared_examples "registered settings pages" do Settings::Pages.all.each do |settings_page| it "lists exactly the settings rendered on the #{settings_page.key} page", :aggregate_failures do get url_for(**settings_page.url, only_path: true) + entries = settings_page.sections.flat_map(&:visible_entries) + form_fields, other = entries.partition(&:form_field?) + expect(response).to have_http_status(:ok) - expect(rendered_setting_names).to match_array(settings_page.sections.flat_map(&:visible_entries).map(&:name)) + expect(rendered_setting_names).to match_array(form_fields.map(&:name)) + expect(other.map(&:name) - marked_setting_names).to be_empty end end end it_behaves_like "registered settings pages" + + context "with optional states enabled", + with_settings: { + work_package_multiple_versions: false, + real_time_text_collaboration_enabled: true, + collaborative_editing_hocuspocus_url: "wss://hocuspocus.example.com", + collaborative_editing_hocuspocus_secret: "secret" + } do + it_behaves_like "registered settings pages" + end end From 9ab8caa399bbbf2fc4c825ab2d45a0e930de9031 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:12 +0100 Subject: [PATCH 04/16] Build a tree of the searchable global settings Settings::SearchTree arranges the registered settings below the admin menu items and tabs they are shown on, with links highlighting the setting on its page. Menu items hidden by configuration or not visible to the current user are left out. --- app/models/settings/search_tree.rb | 98 +++++++++++++++++++ spec/models/settings/search_tree_spec.rb | 115 +++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 app/models/settings/search_tree.rb create mode 100644 spec/models/settings/search_tree_spec.rb diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb new file mode 100644 index 000000000000..1a745b1eb25c --- /dev/null +++ b/app/models/settings/search_tree.rb @@ -0,0 +1,98 @@ +# 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 + class SearchTree + Node = Data.define(:key, :label, :description, :href, :children) + + def initialize(view_context, + pages: Settings::Pages.all, + menu: Settings::Pages.admin_menu, + hidden_menu_items: OpenProject::Configuration.hidden_menu_items["admin_menu"] || []) + @view_context = view_context + @pages_by_menu_item = pages.group_by(&:menu_item) + @menu = menu + @hidden_menu_items = hidden_menu_items.map(&:to_s) + end + + def nodes + @nodes ||= build(@menu) + end + + private + + attr_reader :view_context + + def build(menu_node) + menu_node.children.filter_map do |item| + next unless visible_menu_item?(item) + + children = [*page_nodes(item), *build(item)] + next if children.empty? + + Node.new(key: item.name.to_s, label: item.caption, description: nil, href: view_context.url_for(item.url), children:) + end + end + + def page_nodes(item) + @pages_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |page| + leaves = setting_nodes(page) + + if page.label && leaves.any? + [Node.new(key: page.key.to_s, + label: view_context.t(page.label), + description: nil, + href: view_context.url_for(page.url(@menu)), + children: leaves)] + else + leaves + end + end + end + + def setting_nodes(page) + page.sections.flat_map(&:visible_entries).map do |entry| + Node.new(key: "#{page.key}-#{entry.name}", + label: entry.label(view_context), + description: plain_text(entry.caption(view_context)), + href: view_context.url_for(page.url(@menu).merge(highlight: entry.name)), + children: []) + end + end + + def plain_text(html) + view_context.strip_tags(html)&.squish.presence + end + + def visible_menu_item?(item) + (item.condition.nil? || item.condition.call(nil)) && @hidden_menu_items.exclude?(item.name.to_s) + end + end +end diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb new file mode 100644 index 000000000000..cf04af506bd2 --- /dev/null +++ b/spec/models/settings/search_tree_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" + +RSpec.describe Settings::SearchTree do + include ViewComponent::TestHelpers + + shared_let(:admin) { create(:admin) } + + let(:hidden_menu_items) { [] } + + subject(:tree) { described_class.new(vc_test_controller.view_context, hidden_menu_items:) } + + def find_node(nodes, *labels) + labels.reduce(nil) do |node, label| + level = node ? node.children : nodes + level.find { it.label == label } || raise("No node #{label.inspect} among #{level.map(&:label).inspect}") + end + end + + context "as an admin" do + current_user { admin } + + it "places settings below their admin menu items" do + node = find_node(tree.nodes, "System settings", "General", "Application title") + + expect(node.href).to eq "/admin/settings/general?highlight=app_title" + expect(node.children).to be_empty + end + + it "places settings of tabbed pages below the tab" do + node = find_node(tree.nodes, "Authentication", "Login and registration", "Passwords", "Minimum length") + + expect(node.href).to eq "/admin/settings/authentication?highlight=password_min_length&tab=passwords" + end + + it "links menu items and tabs" do + expect(find_node(tree.nodes, "System settings", "External links").href).to eq "/admin/settings/external_links" + expect(find_node(tree.nodes, "Authentication", "Login and registration", "Passwords").href) + .to eq "/admin/settings/authentication?tab=passwords" + end + + it "uses the plain text caption as description" do + node = find_node(tree.nodes, "System settings", "General", "Host name") + + expect(node.description).to eq "Example: test.host" + end + + it "omits menu items without registered settings" do + expect(tree.nodes.map(&:label)).not_to include("Overview", "Information") + end + + it "omits the multiple versions conversion once it is done" do + expect(find_node(tree.nodes, "Work packages").children.map(&:label)).not_to include("Versions and categories") + end + + it "describes real-time collaboration independently of its state" do + node = find_node(tree.nodes, "Documents", "Real-time collaboration", "Real-time collaboration") + + expect(node.description) + .to eq "Allows multiple users to edit a document at the same time. Requires a working Hocuspocus server." + end + + context "with the multiple versions conversion pending", with_settings: { work_package_multiple_versions: false } do + it "lists the conversion below its menu item" do + node = find_node(tree.nodes, "Work packages", "Versions and categories", "Multiple target versions") + + expect(node.href).to eq "/admin/settings/versions_and_categories?highlight=work_package_multiple_versions" + end + end + + context "with a hidden menu item" do + let(:hidden_menu_items) { ["settings_general"] } + + it "omits its settings" do + expect(find_node(tree.nodes, "System settings").children.map(&:label)).not_to include("General") + end + end + end + + context "as a regular user" do + current_user { create(:user) } + + it "is empty" do + expect(tree.nodes).to be_empty + end + end +end From 87fcf7cc7bbaacbb71f3fb9d228899248dd84387 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:12 +0100 Subject: [PATCH 05/16] Add a settings search to the admin menu Show a button above the admin side menu opening an overlay with the tree of global settings. The tree is loaded lazily into a turbo frame from Admin::SettingsSearchesController when the overlay is first opened, so admin pages do not build it on every request. Scope the user search button click in the accents filter spec, as the settings search button also matches the accessible name "Search". --- app/components/_index.sass | 1 + .../settings_search/tree_component.html.erb | 50 +++++++++++++++ .../admin/settings_search/tree_component.rb | 62 +++++++++++++++++++ .../admin/settings_search_component.html.erb | 55 ++++++++++++++++ .../admin/settings_search_component.rb | 39 ++++++++++++ .../admin/settings_search_component.sass | 41 ++++++++++++ .../admin/settings_searches_controller.rb | 39 ++++++++++++ app/views/layouts/admin.html.erb | 4 ++ app/views/layouts/base.html.erb | 1 + config/locales/en.yml | 5 ++ config/routes.rb | 2 + .../settings_search/tree_component_spec.rb | 62 +++++++++++++++++++ .../admin/settings_search_component_spec.rb | 54 ++++++++++++++++ .../users/unaccent_user_filter_spec.rb | 2 +- spec/requests/admin/settings_search_spec.rb | 55 ++++++++++++++++ 15 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 app/components/admin/settings_search/tree_component.html.erb create mode 100644 app/components/admin/settings_search/tree_component.rb create mode 100644 app/components/admin/settings_search_component.html.erb create mode 100644 app/components/admin/settings_search_component.rb create mode 100644 app/components/admin/settings_search_component.sass create mode 100644 app/controllers/admin/settings_searches_controller.rb create mode 100644 spec/components/admin/settings_search/tree_component_spec.rb create mode 100644 spec/components/admin/settings_search_component_spec.rb create mode 100644 spec/requests/admin/settings_search_spec.rb diff --git a/app/components/_index.sass b/app/components/_index.sass index f932f2fafe81..70ecd0c6f244 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,3 +1,4 @@ +@import "admin/settings_search_component" @import "ai/text_transforms/result_pane_component" @import "colors/swatch_component" @import "enterprise_edition/banner_component" diff --git a/app/components/admin/settings_search/tree_component.html.erb b/app/components/admin/settings_search/tree_component.html.erb new file mode 100644 index 000000000000..1119256c2518 --- /dev/null +++ b/app/components/admin/settings_search/tree_component.html.erb @@ -0,0 +1,50 @@ +<%#-- 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. + +++#%> + +<%= helpers.turbo_frame_tag Admin::SettingsSearchComponent::FRAME_ID, class: "op-admin-settings-search--frame" do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-admin-settings-search--tree", data: { controller: "admin--settings-search" })) do %> + <%= render( + Primer::OpenProject::FilterableTreeView.new( + show_search_highlighting: true, + tree_view_arguments: { node_variant: :anchor }, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: { hidden: true }, + filter_input_arguments: { + autofocus: true, + autocomplete: "off", + placeholder: t("admin.settings_search.filter_placeholder"), + data: { test_selector: "op-admin-settings-search--filter" } + }, + no_results_node_arguments: { label: t("admin.settings_search.no_results") } + ) + ) do |tree_view| %> + <% @tree.nodes.each { add_node(tree_view, it) } %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/admin/settings_search/tree_component.rb b/app/components/admin/settings_search/tree_component.rb new file mode 100644 index 000000000000..252b82d1a999 --- /dev/null +++ b/app/components/admin/settings_search/tree_component.rb @@ -0,0 +1,62 @@ +# 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 SettingsSearch + class TreeComponent < ApplicationComponent + def initialize(tree:) + super() + @tree = tree + end + + private + + def add_node(parent, node) + if node.children.any? + parent.with_sub_tree(label: node.label, href: node.href, select_variant: :none, data: node_data(node)) do |sub_tree| + node.children.each { add_node(sub_tree, it) } + end + else + parent.with_leaf(label: leaf_label(node), href: node.href, select_variant: :none, data: node_data(node)) + end + end + + def leaf_label(node) + return node.label if node.description.blank? + + safe_join([node.label, tag.span(node.description, class: "sr-only")]) + end + + def node_data(node) + { node_id: node.key, test_selector: "op-admin-settings-search--node" } + end + end + end +end diff --git a/app/components/admin/settings_search_component.html.erb b/app/components/admin/settings_search_component.html.erb new file mode 100644 index 000000000000..6e9e58196941 --- /dev/null +++ b/app/components/admin/settings_search_component.html.erb @@ -0,0 +1,55 @@ +<%#-- 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. + +++#%> + +<%= render(Primer::Box.new(px: 2, pb: 2, data: { test_selector: "op-admin-settings-search" })) do %> + <%= render( + Primer::Alpha::Overlay.new( + title: t("admin.settings_search.title"), + visually_hide_title: true, + anchor_side: :outside_bottom, + anchor_align: :start, + size: :large, + padding: :none, + role: :dialog + ) + ) do |overlay| %> + <% overlay.with_show_button(block: true, data: { test_selector: "op-admin-settings-search--button" }) do |button| %> + <% button.with_leading_visual_icon(icon: :search) %> + <%= t("admin.settings_search.button") %> + <% end %> + + <% overlay.with_body(classes: "op-admin-settings-search--body") do %> + <%= helpers.turbo_frame_tag FRAME_ID, + src: helpers.admin_settings_search_path, + loading: :lazy, + target: "_top", + class: "op-admin-settings-search--frame" %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/admin/settings_search_component.rb b/app/components/admin/settings_search_component.rb new file mode 100644 index 000000000000..3c562b4525db --- /dev/null +++ b/app/components/admin/settings_search_component.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 Admin + class SettingsSearchComponent < ApplicationComponent + FRAME_ID = "op-admin-settings-search-frame" + + def render? + User.current.admin? + end + end +end diff --git a/app/components/admin/settings_search_component.sass b/app/components/admin/settings_search_component.sass new file mode 100644 index 000000000000..de6b3204300f --- /dev/null +++ b/app/components/admin/settings_search_component.sass @@ -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. +//++ + +.op-admin-settings-search + &--body + display: flex + max-height: 75dvh + overflow: hidden + margin: var(--base-size-16) + margin-right: 0 + + &--frame, + &--tree + display: flex + max-height: 100% + flex-basis: 100% diff --git a/app/controllers/admin/settings_searches_controller.rb b/app/controllers/admin/settings_searches_controller.rb new file mode 100644 index 000000000000..f72a788035e9 --- /dev/null +++ b/app/controllers/admin/settings_searches_controller.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 Admin + class SettingsSearchesController < ApplicationController + before_action :require_admin + + def show + render Admin::SettingsSearch::TreeComponent.new(tree: ::Settings::SearchTree.new(view_context)), layout: false + end + end +end diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index ea911add2e86..c874e116da65 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -28,4 +28,8 @@ See COPYRIGHT and LICENSE files for more details. <% @page_header_title = t(:label_administration) %> +<% content_for :main_menu_top do %> + <%= render(Admin::SettingsSearchComponent.new) %> +<% end %> + <%= render template: "layouts/base", locals: { menu_name: :admin_menu } %> diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index f7b987b26b64..ed469dfc8e24 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -122,6 +122,7 @@ See COPYRIGHT and LICENSE files for more details. ) ) %> + <%= content_for :main_menu_top %> <%= main_menu %> <%= content_for :main_menu %> <%= call_hook :view_layouts_base_main_menu %> diff --git a/config/locales/en.yml b/config/locales/en.yml index d7512c25ee12..497f654c140b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2349,6 +2349,11 @@ en: 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. success_banner: Successfully updated work package identifier format. + settings_search: + button: "Search settings" + filter_placeholder: "Search by name or description" + no_results: "No settings found" + title: "Search settings" text_transform_actions: delete_dialog_component: description: "Users will no longer be able to run this action in the rich text editor." diff --git a/config/routes.rb b/config/routes.rb index fbdc00bfec04..c4ccab69fc6e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -917,6 +917,8 @@ end namespace :admin do + resource :settings_search, only: :show + namespace :settings do resource :general, controller: "/admin/settings/general_settings", only: %i[show update] resource :languages, controller: "/admin/settings/languages_settings", only: %i[show update] diff --git a/spec/components/admin/settings_search/tree_component_spec.rb b/spec/components/admin/settings_search/tree_component_spec.rb new file mode 100644 index 000000000000..499e52f7ca20 --- /dev/null +++ b/spec/components/admin/settings_search/tree_component_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. + +require "rails_helper" + +RSpec.describe Admin::SettingsSearch::TreeComponent, type: :component do + let(:node) { Settings::SearchTree::Node } + let(:nodes) do + [ + node.new(key: "settings", label: "System settings", description: nil, href: "/admin/settings/general", children: [ + node.new(key: "general-app_title", label: "Application title", description: "Shown in the header", + href: "/admin/settings/general?highlight=app_title", children: []) + ]) + ] + end + let(:tree) { instance_double(Settings::SearchTree, nodes:) } + + subject(:rendered_component) do + render_inline(described_class.new(tree:)) + page + end + + it "renders the menu hierarchy with settings as links", :aggregate_failures do + expect(rendered_component).to have_link "System settings", href: "/admin/settings/general", visible: :all + expect(rendered_component).to have_link "Application title", href: "/admin/settings/general?highlight=app_title", + visible: :all + end + + it "includes the description as searchable, visually hidden text" do + expect(rendered_component).to have_css(".sr-only", text: "Shown in the header", visible: :all) + end + + it "renders a filter input" do + expect(rendered_component).to have_field placeholder: "Search by name or description" + end +end diff --git a/spec/components/admin/settings_search_component_spec.rb b/spec/components/admin/settings_search_component_spec.rb new file mode 100644 index 000000000000..bcc8aee09831 --- /dev/null +++ b/spec/components/admin/settings_search_component_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. + +require "rails_helper" + +RSpec.describe Admin::SettingsSearchComponent, type: :component do + subject(:rendered_component) do + render_inline(described_class.new) + page + end + + context "as an admin" do + current_user { build_stubbed(:admin) } + + it "renders a button opening the lazily loaded settings tree", :aggregate_failures do + expect(rendered_component).to have_button "Search settings" + expect(rendered_component).to have_css("turbo-frame[src='/admin/settings_search'][loading='lazy']", visible: :all) + end + end + + context "as a regular user" do + current_user { build_stubbed(:user) } + + it "renders nothing" do + expect(rendered_component).to have_no_css("*") + end + end +end diff --git a/spec/features/users/unaccent_user_filter_spec.rb b/spec/features/users/unaccent_user_filter_spec.rb index acf7339efb9b..6d5249bcc84b 100644 --- a/spec/features/users/unaccent_user_filter_spec.rb +++ b/spec/features/users/unaccent_user_filter_spec.rb @@ -50,7 +50,7 @@ it "finds a user with accents in the name in the global administration" do visit users_path - click_button accessible_name: "Search" + within("#content") { click_button accessible_name: "Search" } fill_in "Search", with: "Cecile" wait_for_network_idle expect(page).to have_css("td.firstname", text: "Cécile") diff --git a/spec/requests/admin/settings_search_spec.rb b/spec/requests/admin/settings_search_spec.rb new file mode 100644 index 000000000000..7d9ef7c67400 --- /dev/null +++ b/spec/requests/admin/settings_search_spec.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. + +require "spec_helper" + +RSpec.describe "Admin settings search tree", type: :rails_request do + before do + login_as(user) + get admin_settings_search_path + end + + context "as an admin" do + let(:user) { create(:admin) } + + it "renders the settings tree in its turbo frame", :aggregate_failures do + expect(response).to have_http_status(:ok) + expect(response.parsed_body.at_css("turbo-frame##{Admin::SettingsSearchComponent::FRAME_ID}")).to be_present + expect(response.body).to include("/admin/settings/general?highlight=app_title") + end + end + + context "as a regular user" do + let(:user) { create(:user) } + + it "is forbidden" do + expect(response).to have_http_status(:forbidden) + end + end +end From 5421d95a90d0be5798ba42fdb417816eeedf0192 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:19 +0100 Subject: [PATCH 06/16] Filter the settings search tree on the server The filterable tree view loads its tree from an endpoint taking the query, so that entities such as work package types, which cannot all be sent to the browser, can be included later. Each word of the query is matched regardless of order and punctuation, so "self registration" finds "Self-registration", against a setting, its description or the menu items and tabs above it. All settings below a matching menu item or tab stay visible, matching branches are expanded and the query is highlighted. --- .../admin/settings_search/filter_component.rb | 56 +++++++++++++++++++ .../settings_search/tree_component.html.erb | 29 ++++------ .../admin/settings_search/tree_component.rb | 15 +++-- .../admin/settings_search_component.sass | 7 ++- .../admin/settings_searches_controller.rb | 7 ++- app/models/settings/search_tree.rb | 32 ++++++++++- config/routes.rb | 4 +- .../settings_search/tree_component_spec.rb | 20 +++++-- spec/models/settings/search_tree_spec.rb | 45 ++++++++++++++- spec/requests/admin/settings_search_spec.rb | 27 ++++++--- 10 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 app/components/admin/settings_search/filter_component.rb diff --git a/app/components/admin/settings_search/filter_component.rb b/app/components/admin/settings_search/filter_component.rb new file mode 100644 index 000000000000..b454f0cd3d61 --- /dev/null +++ b/app/components/admin/settings_search/filter_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 Admin + module SettingsSearch + class FilterComponent < ApplicationComponent + def call + helpers.turbo_frame_tag(Admin::SettingsSearchComponent::FRAME_ID, class: "op-admin-settings-search--frame") do + render( + Primer::OpenProject::FilterableTreeView.new( + src: helpers.tree_admin_settings_search_path, + classes: "op-admin-settings-search--tree", + show_search_highlighting: false, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: { hidden: true }, + filter_input_arguments: { + autofocus: true, + autocomplete: "off", + placeholder: t("admin.settings_search.filter_placeholder"), + data: { test_selector: "op-admin-settings-search--filter" } + }, + no_results_node_arguments: { label: t("admin.settings_search.no_results") } + ) + ) + end + end + end + end +end diff --git a/app/components/admin/settings_search/tree_component.html.erb b/app/components/admin/settings_search/tree_component.html.erb index 1119256c2518..a4cf25473188 100644 --- a/app/components/admin/settings_search/tree_component.html.erb +++ b/app/components/admin/settings_search/tree_component.html.erb @@ -27,24 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= helpers.turbo_frame_tag Admin::SettingsSearchComponent::FRAME_ID, class: "op-admin-settings-search--frame" do %> - <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-admin-settings-search--tree", data: { controller: "admin--settings-search" })) do %> - <%= render( - Primer::OpenProject::FilterableTreeView.new( - show_search_highlighting: true, - tree_view_arguments: { node_variant: :anchor }, - include_sub_items_check_box_arguments: { hidden: true }, - filter_mode_control_arguments: { hidden: true }, - filter_input_arguments: { - autofocus: true, - autocomplete: "off", - placeholder: t("admin.settings_search.filter_placeholder"), - data: { test_selector: "op-admin-settings-search--filter" } - }, - no_results_node_arguments: { label: t("admin.settings_search.no_results") } - ) - ) do |tree_view| %> - <% @tree.nodes.each { add_node(tree_view, it) } %> - <% end %> +<%= render( + Primer::Alpha::TreeView.new( + node_variant: :anchor, + data: { target: "filterable-tree-view.treeViewList" } + ) + ) do |tree_view| %> + <% if @tree.nodes.empty? %> + <% tree_view.with_leaf(label: t("admin.settings_search.no_results"), disabled: true) %> + <% else %> + <% @tree.nodes.each { add_node(tree_view, it) } %> <% end %> <% end %> diff --git a/app/components/admin/settings_search/tree_component.rb b/app/components/admin/settings_search/tree_component.rb index 252b82d1a999..1ef82bc04dea 100644 --- a/app/components/admin/settings_search/tree_component.rb +++ b/app/components/admin/settings_search/tree_component.rb @@ -31,6 +31,8 @@ module Admin module SettingsSearch class TreeComponent < ApplicationComponent + include SearchHighlightingHelper + def initialize(tree:) super() @tree = tree @@ -40,18 +42,21 @@ def initialize(tree:) def add_node(parent, node) if node.children.any? - parent.with_sub_tree(label: node.label, href: node.href, select_variant: :none, data: node_data(node)) do |sub_tree| + parent.with_sub_tree(label: label(node), href: node.href, expanded: searching?, select_variant: :none, + data: node_data(node)) do |sub_tree| node.children.each { add_node(sub_tree, it) } end else - parent.with_leaf(label: leaf_label(node), href: node.href, select_variant: :none, data: node_data(node)) + parent.with_leaf(label: label(node), href: node.href, select_variant: :none, data: node_data(node)) end end - def leaf_label(node) - return node.label if node.description.blank? + def label(node) + searching? ? highlight_text_by_terms(node.label, @tree.query_words) : node.label + end - safe_join([node.label, tag.span(node.description, class: "sr-only")]) + def searching? + @tree.query_words.any? end def node_data(node) diff --git a/app/components/admin/settings_search_component.sass b/app/components/admin/settings_search_component.sass index de6b3204300f..5d5fce58dfcb 100644 --- a/app/components/admin/settings_search_component.sass +++ b/app/components/admin/settings_search_component.sass @@ -34,8 +34,11 @@ margin: var(--base-size-16) margin-right: 0 - &--frame, - &--tree + &--frame display: flex max-height: 100% flex-basis: 100% + + &--tree + flex-grow: 1 + min-width: 0 diff --git a/app/controllers/admin/settings_searches_controller.rb b/app/controllers/admin/settings_searches_controller.rb index f72a788035e9..df3c85982858 100644 --- a/app/controllers/admin/settings_searches_controller.rb +++ b/app/controllers/admin/settings_searches_controller.rb @@ -33,7 +33,12 @@ class SettingsSearchesController < ApplicationController before_action :require_admin def show - render Admin::SettingsSearch::TreeComponent.new(tree: ::Settings::SearchTree.new(view_context)), layout: false + render Admin::SettingsSearch::FilterComponent.new, layout: false + end + + def tree + render Admin::SettingsSearch::TreeComponent.new(tree: ::Settings::SearchTree.new(view_context, query: params[:query])), + layout: false end end end diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index 1a745b1eb25c..ad3dc9671338 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -29,21 +29,35 @@ #++ module Settings + # The settings shown in the settings search, structured like the admin menu. + # + # With a query, only nodes whose words, together with those of their + # ancestors, contain every word of the query are kept, including everything + # below them. Words are compared case insensitively and regardless of + # punctuation, so "self registration" finds "Self-registration". class SearchTree Node = Data.define(:key, :label, :description, :href, :children) + def self.words(text) + text.to_s.downcase.split(/[^\p{L}\p{N}]+/).compact_blank + end + def initialize(view_context, + query: nil, pages: Settings::Pages.all, menu: Settings::Pages.admin_menu, hidden_menu_items: OpenProject::Configuration.hidden_menu_items["admin_menu"] || []) @view_context = view_context + @query_words = self.class.words(query) @pages_by_menu_item = pages.group_by(&:menu_item) @menu = menu @hidden_menu_items = hidden_menu_items.map(&:to_s) end + attr_reader :query_words + def nodes - @nodes ||= build(@menu) + @nodes ||= filter(build(@menu), []) end private @@ -87,6 +101,22 @@ def setting_nodes(page) end end + def filter(nodes, ancestor_words) + return nodes if query_words.empty? + + nodes.filter_map do |node| + path_words = [*ancestor_words, *self.class.words(node.label)] + next node if matches?([*path_words, *self.class.words(node.description)]) + + children = filter(node.children, path_words) + node.with(children:) if children.any? + end + end + + def matches?(words) + query_words.all? { |query_word| words.any? { it.include?(query_word) } } + end + def plain_text(html) view_context.strip_tags(html)&.squish.presence end diff --git a/config/routes.rb b/config/routes.rb index c4ccab69fc6e..2db4864d4769 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -917,7 +917,9 @@ end namespace :admin do - resource :settings_search, only: :show + resource :settings_search, only: :show do + get :tree + end namespace :settings do resource :general, controller: "/admin/settings/general_settings", only: %i[show update] diff --git a/spec/components/admin/settings_search/tree_component_spec.rb b/spec/components/admin/settings_search/tree_component_spec.rb index 499e52f7ca20..81bf56d33069 100644 --- a/spec/components/admin/settings_search/tree_component_spec.rb +++ b/spec/components/admin/settings_search/tree_component_spec.rb @@ -39,7 +39,8 @@ ]) ] end - let(:tree) { instance_double(Settings::SearchTree, nodes:) } + let(:query_words) { [] } + let(:tree) { instance_double(Settings::SearchTree, nodes:, query_words:) } subject(:rendered_component) do render_inline(described_class.new(tree:)) @@ -52,11 +53,20 @@ visible: :all end - it "includes the description as searchable, visually hidden text" do - expect(rendered_component).to have_css(".sr-only", text: "Shown in the header", visible: :all) + context "when searching" do + let(:query_words) { ["title"] } + + it "highlights the query and expands the hierarchy", :aggregate_failures do + expect(rendered_component).to have_css(".op-search-highlight", text: "title") + expect(rendered_component).to have_css("[aria-expanded='true']", text: "System settings") + end end - it "renders a filter input" do - expect(rendered_component).to have_field placeholder: "Search by name or description" + context "without any nodes" do + let(:nodes) { [] } + + it "says so" do + expect(rendered_component).to have_text "No settings found" + end end end diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index cf04af506bd2..c162d7846373 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -35,8 +35,11 @@ shared_let(:admin) { create(:admin) } let(:hidden_menu_items) { [] } + let(:query) { nil } - subject(:tree) { described_class.new(vc_test_controller.view_context, hidden_menu_items:) } + subject(:tree) { described_class.new(vc_test_controller.view_context, query:, hidden_menu_items:) } + + def leaf_labels(nodes) = nodes.flat_map { it.children.empty? ? [it.label] : leaf_labels(it.children) } def find_node(nodes, *labels) labels.reduce(nil) do |node, label| @@ -96,6 +99,46 @@ def find_node(nodes, *labels) end end + context "with a query" do + let(:query) { "registration self" } + + it "keeps only matching settings regardless of word order and punctuation" do + expect(leaf_labels(tree.nodes)).to eq ["Self-registration"] + end + end + + context "with a query matching a tab" do + let(:query) { "passwords" } + + it "keeps all settings below it" do + expect(leaf_labels(tree.nodes)).to include("Minimum length", "Enable password reset") + end + end + + context "with a query matching a tab and a setting" do + let(:query) { "passwords minimum" } + + it "keeps only that setting" do + expect(leaf_labels(tree.nodes)).to eq ["Minimum length"] + end + end + + context "with a query matching a description" do + let(:query) { "comma separated" } + + it "keeps the described settings" do + expect(leaf_labels(tree.nodes)).to include("Objects per page options") + end + end + + context "with a query matching nothing" do + let(:query) { "xyzzy" } + + it "is empty" do + expect(tree.nodes).to be_empty + end + end + context "with a hidden menu item" do let(:hidden_menu_items) { ["settings_general"] } diff --git a/spec/requests/admin/settings_search_spec.rb b/spec/requests/admin/settings_search_spec.rb index 7d9ef7c67400..e23d168a6c00 100644 --- a/spec/requests/admin/settings_search_spec.rb +++ b/spec/requests/admin/settings_search_spec.rb @@ -29,26 +29,37 @@ require "spec_helper" -RSpec.describe "Admin settings search tree", type: :rails_request do - before do - login_as(user) - get admin_settings_search_path - end +RSpec.describe "Admin settings search", type: :rails_request do + before { login_as(user) } context "as an admin" do let(:user) { create(:admin) } - it "renders the settings tree in its turbo frame", :aggregate_failures do + it "renders the filterable tree in its turbo frame, loading the tree from the server", :aggregate_failures do + get admin_settings_search_path + + expect(response).to have_http_status(:ok) + frame = response.parsed_body.at_css("turbo-frame##{Admin::SettingsSearchComponent::FRAME_ID}") + expect(frame.at_css("filterable-tree-view")["src"]).to eq tree_admin_settings_search_path + end + + it "renders the tree filtered by the query", :aggregate_failures do + get tree_admin_settings_search_path, params: { query: "application title" } + expect(response).to have_http_status(:ok) - expect(response.parsed_body.at_css("turbo-frame##{Admin::SettingsSearchComponent::FRAME_ID}")).to be_present expect(response.body).to include("/admin/settings/general?highlight=app_title") + expect(response.body).not_to include("highlight=host_name") end end context "as a regular user" do let(:user) { create(:user) } - it "is forbidden" do + it "is forbidden", :aggregate_failures do + get admin_settings_search_path + expect(response).to have_http_status(:forbidden) + + get tree_admin_settings_search_path expect(response).to have_http_status(:forbidden) end end From be5578feff94387f9fd5fb4b273e107f4fa2d821 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:19 +0100 Subject: [PATCH 07/16] Highlight the setting selected in the settings search The highlight-setting controller scrolls to and highlights the field named in the `highlight` parameter, or the element marked with its `data-setting-name`, when a settings page is opened from the search. A feature spec covers searching settings and opening them highlighted in the browser. --- .../admin/settings_search_component.sass | 11 ++ app/helpers/application_helper.rb | 3 +- .../highlight-setting.controller.ts | 63 +++++++++++ frontend/src/stimulus/setup.ts | 2 + spec/features/admin/settings_search_spec.rb | 103 ++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 frontend/src/stimulus/controllers/highlight-setting.controller.ts create mode 100644 spec/features/admin/settings_search_spec.rb diff --git a/app/components/admin/settings_search_component.sass b/app/components/admin/settings_search_component.sass index 5d5fce58dfcb..b9016ed2c299 100644 --- a/app/components/admin/settings_search_component.sass +++ b/app/components/admin/settings_search_component.sass @@ -42,3 +42,14 @@ &--tree flex-grow: 1 min-width: 0 + +@keyframes op-setting-highlight-fade + to + background-color: transparent + box-shadow: none + +.op-setting--highlighted + border-radius: var(--borderRadius-medium) + background-color: var(--bgColor-attention-muted) + box-shadow: 0 0 0 var(--base-size-8) var(--bgColor-attention-muted) + animation: op-setting-highlight-fade 1s ease-out 3s forwards diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 46338693b639..67db154c0fa0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -287,7 +287,8 @@ def comment_sort_order_options def body_data_attributes(local_assigns) { - controller: ["application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element", + controller: ["application auto-theme-switcher hover-card-trigger beforeunload external-links", + "highlight-target-element highlight-setting", stimulus_body_controller].compact.join(" "), relative_url_root: root_path, overflowing_identifier: ".__overflowing_body", diff --git a/frontend/src/stimulus/controllers/highlight-setting.controller.ts b/frontend/src/stimulus/controllers/highlight-setting.controller.ts new file mode 100644 index 000000000000..32938011044f --- /dev/null +++ b/frontend/src/stimulus/controllers/highlight-setting.controller.ts @@ -0,0 +1,63 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ApplicationController } from 'stimulus-use'; + +export default class HighlightSettingController extends ApplicationController { + connect() { + const url = new URL(window.location.href); + const name = url.searchParams.get('highlight'); + if (!name || !/^\w+$/.test(name)) return; + + const container = this.findContainer(name); + if (!container) return; + + container.classList.add('op-setting--highlighted'); + setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'center' }), 25); + + url.searchParams.delete('highlight'); + window.history.replaceState(window.history.state, '', url); + } + + private findContainer(name:string):HTMLElement|null { + const marked = document.querySelector(`[data-setting-name="${name}"]`); + if (marked) { + return marked.closest('.FormControl-checkbox-wrap, .FormControl') ?? marked; + } + + const fields = Array.from(document.querySelectorAll( + `[name="settings[${name}]"]:not([type="hidden"]), [name^="settings[${name}]["]:not([type="hidden"])`, + )); + if (fields.length === 0) return null; + + if (fields.length > 1) { + return fields[0].closest('fieldset, .form--field') ?? fields[0]; + } + + return fields[0].closest('.FormControl-checkbox-wrap, .FormControl, .form--field') ?? fields[0]; + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 03b582d88c5a..ed6b3090a7cd 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -74,6 +74,7 @@ import { BeforeunloadController } from './controllers/beforeunload.controller'; import ExternalLinksController from './controllers/external-links.controller'; import DisableWhenClickedController from 'core-stimulus/controllers/disable-when-clicked.controller'; import HighlightTargetElementController from 'core-stimulus/controllers/highlight-target-element.controller'; +import HighlightSettingController from 'core-stimulus/controllers/highlight-setting.controller'; import SelectAutosizeController from 'core-stimulus/controllers/select-autosize.controller'; import OpZenModeController from 'core-stimulus/controllers/zen-mode.controller'; import CheckAllController from 'core-stimulus/controllers/check-all.controller'; @@ -124,6 +125,7 @@ OpenProjectStimulusApplication.preregister('auto-theme-switcher', AutoThemeSwitc OpenProjectStimulusApplication.preregister('theme-selector', ThemeSelectorController); OpenProjectStimulusApplication.preregister('external-links', ExternalLinksController); OpenProjectStimulusApplication.preregister('highlight-target-element', HighlightTargetElementController); +OpenProjectStimulusApplication.preregister('highlight-setting', HighlightSettingController); OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeController); OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); OpenProjectStimulusApplication.preregister('users--working-hours-form', WorkingHoursFormController); diff --git a/spec/features/admin/settings_search_spec.rb b/spec/features/admin/settings_search_spec.rb new file mode 100644 index 000000000000..db5653f3b89e --- /dev/null +++ b/spec/features/admin/settings_search_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 "spec_helper" + +RSpec.describe "Searching settings in the administration", :js do + current_user { create(:admin) } + + before do + visit admin_settings_working_days_and_hours_path + + within("#main-menu") { click_on "Search settings" } + end + + def search_for(query) + find_test_selector("op-admin-settings-search--filter", wait: 20).fill_in(with: query) + end + + it "filters the settings tree and highlights the selected setting" do + search_for "host name" + + within_test_selector("op-admin-settings-search") do + expect(page).to have_link "Host name" + expect(page).to have_no_link "Application title" + + click_on "Host name" + end + + expect(page).to have_current_path admin_settings_general_path, wait: 20 + expect(page).to have_css(".op-setting--highlighted", text: "Host name") + end + + it "ignores punctuation and word order" do + search_for "registration self" + + within_test_selector("op-admin-settings-search") do + expect(page).to have_link "Self-registration" + expect(page).to have_no_link "Application title" + end + end + + it "lists all settings below a matching menu item or tab" do + search_for "passwords" + + within_test_selector("op-admin-settings-search") do + expect(page).to have_link "Minimum length" + expect(page).to have_no_link "Application title" + end + end + + it "combines words matching a tab and a setting" do + search_for "passwords minimum" + + within_test_selector("op-admin-settings-search") do + expect(page).to have_link "Minimum length" + expect(page).to have_no_link "Enable password reset" + end + end + + it "highlights settings not changed through a form field", with_flag: { ai_text_transform_actions: true } do + search_for "text transform" + + within_test_selector("op-admin-settings-search") { click_on "Enable text transform actions" } + + expect(page).to have_current_path admin_text_transform_actions_path, wait: 20 + expect(page).to have_css(".op-setting--highlighted[data-setting-name='ai_text_transform_actions_enabled']") + end + + it "finds settings by their description" do + search_for "comma separated" + + within_test_selector("op-admin-settings-search") do + expect(page).to have_link "Objects per page options" + expect(page).to have_no_link "Application title" + end + end +end From 4b5b224cd2f736dc87b65df2c315048b8939e350 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:49:04 +0100 Subject: [PATCH 08/16] Extract the visibility of custom field form attributes Move the checks deciding which attributes the custom field details form shows into their own object, so that the settings search can list only the attributes that apply to a custom field. --- .../custom_fields/details_field_visibility.rb | 117 ++++++++++++++++++ app/forms/custom_fields/details_form.rb | 102 ++++----------- 2 files changed, 142 insertions(+), 77 deletions(-) create mode 100644 app/forms/custom_fields/details_field_visibility.rb diff --git a/app/forms/custom_fields/details_field_visibility.rb b/app/forms/custom_fields/details_field_visibility.rb new file mode 100644 index 000000000000..4c8ed0847582 --- /dev/null +++ b/app/forms/custom_fields/details_field_visibility.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module CustomFields + # Which attributes the details form shows for a custom field. + class DetailsFieldVisibility + attr_reader :model + + def initialize(model) + @model = model + end + + def show_section_field? + model.is_a?(ProjectCustomField) || model.is_a?(UserCustomField) + end + + def show_default_bool_field? + %w[bool].include?(model.field_format) + end + + def show_default_text_field? + %w[list bool date datetime text user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format) + end + + def show_default_rich_text_field? + %w[text].include?(model.field_format) + end + + def show_has_comment_field? + model.can_have_comment? + end + + def show_is_required_field? + %w[calculated_value bool].exclude?(model.field_format) + end + + def show_min_max_length_field? + model.length_limits_possible? + end + + def show_min_max_value_field? + model.numeric_bounds_possible? + end + + def show_regex_field? + %w[list bool date datetime user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format) + end + + def show_right_to_left_field? + model.is_a?(WorkPackageCustomField) && %w[text].include?(model.field_format) + end + + def show_multi_value_field? + model.multi_value_possible? + end + + def show_formula_field? + %w[calculated_value].include?(model.field_format) + end + + def show_is_for_all_field? + model.is_a?(ProjectCustomField) + end + + def show_is_filter_field? + model.is_a?(WorkPackageCustomField) + end + + def show_is_searchable_field? + (model.is_a?(WorkPackageCustomField) || model.is_a?(ProjectCustomField)) && + %w[bool date datetime float int user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format) + end + + def show_non_open_versions_field? + %w[version].include?(model.field_format) && model.allow_non_open_versions_possible? + end + + def show_admin_only_field? + model.class.customized_class&.admin_only_custom_fields_allowed? + end + + def show_editable_field? + model.is_a?(UserCustomField) + end + + def show_on_user_card_field? + model.is_a?(UserCustomField) + end + end +end diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 12d4add85df2..8cf25ee73c4a 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -29,6 +29,27 @@ module CustomFields class DetailsForm < ApplicationForm + delegate :show_section_field?, + :show_default_bool_field?, + :show_default_text_field?, + :show_default_rich_text_field?, + :show_has_comment_field?, + :show_is_required_field?, + :show_min_max_length_field?, + :show_min_max_value_field?, + :show_regex_field?, + :show_right_to_left_field?, + :show_multi_value_field?, + :show_formula_field?, + :show_is_for_all_field?, + :show_is_filter_field?, + :show_is_searchable_field?, + :show_non_open_versions_field?, + :show_admin_only_field?, + :show_editable_field?, + :show_on_user_card_field?, + to: :field_visibility + form do |details_form| if model.new_record? details_form.hidden( @@ -231,6 +252,10 @@ class DetailsForm < ApplicationForm details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) end + def field_visibility + @field_visibility ||= DetailsFieldVisibility.new(model) + end + def label(field) I18n.t("activerecord.attributes.custom_field.#{field}") end @@ -245,91 +270,14 @@ def instructions(field) I18n.t(key) end - def show_section_field? - 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? - %w[bool].include?(model.field_format) - end - - def show_default_text_field? - %w[list bool date datetime text user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format) - end - - def show_default_rich_text_field? - %w[text].include?(model.field_format) - end - - def show_has_comment_field? - model.can_have_comment? - end - - def show_is_required_field? - %w[calculated_value bool].exclude?(model.field_format) - end - - def show_min_max_length_field? - model.length_limits_possible? - end - - def show_min_max_value_field? - model.numeric_bounds_possible? - end - def bound_step model.field_format == "int" ? 1 : "any" end - def show_regex_field? - %w[list bool date datetime user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format) - end - - def show_right_to_left_field? - model.is_a?(WorkPackageCustomField) && %w[text].include?(model.field_format) - end - - def show_multi_value_field? - model.multi_value_possible? - end - - def show_formula_field? - %w[calculated_value].include?(model.field_format) - end - - def show_is_for_all_field? - model.is_a?(ProjectCustomField) - end - - def show_is_filter_field? - model.is_a?(WorkPackageCustomField) - end - - def show_is_searchable_field? - (model.is_a?(WorkPackageCustomField) || model.is_a?(ProjectCustomField)) && - %w[bool date datetime float int user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format) - end - - def show_non_open_versions_field? - %w[version].include?(model.field_format) && model.allow_non_open_versions_possible? - end - - def show_admin_only_field? - model.class.customized_class&.admin_only_custom_fields_allowed? - end - - def show_editable_field? - model.is_a?(UserCustomField) - end - - def show_on_user_card_field? - model.is_a?(UserCustomField) - end - # Formula suggestions for operators, punctuation, functions and keywords are inserted as plain text # nodes instead of tokens, since displaying them as tokens would result in too much visual clutter. # We still want to offer autocompletion for them. From 7a6559031b3b82dd75d6a19f8200fdcbf9b0e4de Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:49:04 +0100 Subject: [PATCH 09/16] Find settings of entities in the settings search Register the attributes of work package types, statuses, priorities, roles and custom fields with the settings search. Each kind lists its attributes below its admin menu entry, leading to the first entity they apply to. Entities themselves are only listed when their name matches a word of the query, at most five per kind, so that instances with many types or custom fields keep a usable tree. Selecting an attribute highlights its form field on the entity's page, which may now be given as a field name or as one of several inputs of a field, such as a single permission of a role. --- app/models/settings/search_tree.rb | 67 ++++++- config/constants/settings/entity_kinds.rb | 165 ++++++++++++++++++ config/initializers/settings_entity_kinds.rb | 147 ++++++++++++++++ .../highlight-setting.controller.ts | 51 ++++-- lib/open_project/plugins/acts_as_op_engine.rb | 1 + spec/constants/settings/entity_kinds_spec.rb | 84 +++++++++ spec/features/admin/settings_search_spec.rb | 26 +++ spec/models/settings/search_tree_spec.rb | 63 +++++++ .../admin/settings/entity_attributes_spec.rb | 80 +++++++++ 9 files changed, 667 insertions(+), 17 deletions(-) create mode 100644 config/constants/settings/entity_kinds.rb create mode 100644 config/initializers/settings_entity_kinds.rb create mode 100644 spec/constants/settings/entity_kinds_spec.rb create mode 100644 spec/requests/admin/settings/entity_attributes_spec.rb diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index ad3dc9671338..a4bf6edf1611 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -35,8 +35,12 @@ module Settings # ancestors, contain every word of the query are kept, including everything # below them. Words are compared case insensitively and regardless of # punctuation, so "self registration" finds "Self-registration". + # + # Entities like work package types are only listed when their name matches + # a word of the query, as there may be too many to list them all. class SearchTree Node = Data.define(:key, :label, :description, :href, :children) + EXAMPLE_ENTITIES = 25 def self.words(text) text.to_s.downcase.split(/[^\p{L}\p{N}]+/).compact_blank @@ -45,11 +49,13 @@ def self.words(text) def initialize(view_context, query: nil, pages: Settings::Pages.all, + entity_kinds: Settings::EntityKinds.all, menu: Settings::Pages.admin_menu, hidden_menu_items: OpenProject::Configuration.hidden_menu_items["admin_menu"] || []) @view_context = view_context @query_words = self.class.words(query) @pages_by_menu_item = pages.group_by(&:menu_item) + @entity_kinds_by_menu_item = entity_kinds.group_by(&:menu_item) @menu = menu @hidden_menu_items = hidden_menu_items.map(&:to_s) end @@ -68,7 +74,7 @@ def build(menu_node) menu_node.children.filter_map do |item| next unless visible_menu_item?(item) - children = [*page_nodes(item), *build(item)] + children = [*page_nodes(item), *entity_kind_nodes(item), *build(item)] next if children.empty? Node.new(key: item.name.to_s, label: item.caption, description: nil, href: view_context.url_for(item.url), children:) @@ -101,6 +107,65 @@ def setting_nodes(page) end end + def entity_kind_nodes(item) + @entity_kinds_by_menu_item.fetch(item.name, []).flat_map do |kind| + attributes = kind.attributes + [*attribute_nodes(kind, attributes), *matching_entity_nodes(kind, attributes)] + end + end + + def matching_entity_nodes(kind, attributes) + kind.matching(query_words).filter_map do |entity| + children = attribute_nodes(kind, attributes, entity) + next if children.empty? + + Node.new(key: "#{kind.key}-#{entity.id}", + label: kind.name_of(entity), + description: nil, + href: view_context.instance_exec(entity, &kind.url), + children:) + end + end + + # Without an entity, attributes lead to the first entity they apply to. + def attribute_nodes(kind, attributes, entity = nil) + entries = attributes.filter_map do |attribute| + target = entity || example_entities(kind).find { attribute.applies_to?(it) } + [attribute, attribute_node(kind, attribute, target, entity)] if target && attribute.applies_to?(target) + end + + group_attribute_nodes(kind, entity, entries) + end + + def group_attribute_nodes(kind, entity, entries) + entries.slice_when { |(a, _), (b, _)| a.group != b.group }.flat_map do |chunk| + attribute = chunk.first.first + children = chunk.map(&:last) + attribute.group ? [group_node(kind, attribute, entity, children)] : children + end + end + + def attribute_node(kind, attribute, target, entity) + Node.new(key: [kind.key, entity&.id, attribute.key].compact.join("-"), + label: attribute.label(view_context), + description: nil, + href: attribute.url(target, view_context, kind.url), + children: []) + end + + def group_node(kind, attribute, entity, children) + Node.new(key: [kind.key, entity&.id, attribute.group].compact.join("-"), + label: attribute.group_label(view_context), + description: nil, + href: children.first.href, + children:) + end + + def example_entities(kind) + @example_entities ||= {} + @example_entities[kind.key] ||= kind.entities.limit(EXAMPLE_ENTITIES).to_a + end + def filter(nodes, ancestor_words) return nodes if query_words.empty? diff --git a/config/constants/settings/entity_kinds.rb b/config/constants/settings/entity_kinds.rb new file mode 100644 index 000000000000..ac305e68d4ae --- /dev/null +++ b/config/constants/settings/entity_kinds.rb @@ -0,0 +1,165 @@ +# 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 + # Registry of the kinds of entities, such as work package types, whose + # attributes are found by the settings search. + # + # A kind is listed below its admin menu entry with one node per attribute, + # leading to the attribute on the first entity's page. Entities whose name + # matches the query are listed as well, each with its own attribute nodes. + # + # Attributes link to the kind's `url` unless they have their own (e.g. for + # another tab), and highlight their form `field` there, given as the field + # name or as `name=value` for one of several inputs of the same name. + # Labels and URLs are lambdas evaluated in the view context, URLs receiving + # the entity. An `if` lambda receiving the entity limits the entities an + # attribute applies to and a `group` label nests attributes below a common node. + # + # @example + # Settings::EntityKinds.draw do + # kind :statuses, menu_item: :statuses, scope: -> { Status.order(:position) }, + # url: ->(status) { edit_status_path(status) } do + # attribute :color, label: -> { Status.human_attribute_name(:color) }, field: "status[color_id]" + # end + # end + module EntityKinds + MATCH_LIMIT = 5 + + class Attribute + attr_reader :key, :field, :group + + def initialize(key, label:, url: nil, field: nil, group: nil, **options) + @key = key + @label = label + @url = url + @field = field + @group = group + @condition = options[:if] + end + + def label(view_context) + resolve(@label, view_context) + end + + def group_label(view_context) + resolve(group, view_context) + end + + def applies_to?(entity) + @condition.nil? || @condition.call(entity) + end + + def url(entity, view_context, default_url) + url_proc = @url || default_url + url = view_context.instance_exec(entity, &url_proc) + return url unless field + + separator = url.include?("?") ? "&" : "?" + "#{url}#{separator}#{{ highlight: field }.to_query}" + end + + private + + def resolve(text, view_context) + case text + when Proc then view_context.instance_exec(&text) + when Symbol then view_context.t(text) + else text + end + end + end + + class Kind + attr_reader :key, :menu_item, :url + + def initialize(key, menu_item:, scope:, url:, name: :name) + @key = key + @menu_item = menu_item + @scope = scope + @url = url + @name = name + @attributes = [] + @attribute_sources = [] + end + + def attribute(key, **) + @attributes << Attribute.new(key, **) + end + + def attributes_from(&block) + @attribute_sources << block + end + + def attributes + [*@attributes, *@attribute_sources.flat_map(&:call)] + end + + def entities + @scope.call + end + + def name_of(entity) + entity.public_send(@name) + end + + def matching(words, limit: MATCH_LIMIT) + return entities.none if words.empty? + + column = entities.arel_table[@name] + entities.where(column.matches_any(words.map { "%#{ActiveRecord::Base.sanitize_sql_like(it)}%" })).limit(limit) + end + end + + class << self + def draw(&) + instance_exec(&) + end + + def kind(key, **, &) + registry[key.to_sym] = Kind.new(key.to_sym, **).tap { it.instance_exec(&) if block_given? } + end + + def fetch(key) + registry.fetch(key.to_sym) + end + + def all + registry.values + end + + private + + def registry + @registry ||= {} + end + end + end +end diff --git a/config/initializers/settings_entity_kinds.rb b/config/initializers/settings_entity_kinds.rb new file mode 100644 index 000000000000..8e39233ccd73 --- /dev/null +++ b/config/initializers/settings_entity_kinds.rb @@ -0,0 +1,147 @@ +# 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. +#++ + +Settings::EntityKinds.draw do + kind :types, menu_item: :types, scope: -> { Type.order(:position) }, url: ->(type) { edit_type_details_path(type) } do + attribute :name, label: -> { Type.human_attribute_name(:name) }, field: "type[name]" + attribute :color, label: -> { Color.model_name.human }, field: "type[color_id]" + attribute :is_milestone, label: -> { Type.human_attribute_name(:is_milestone) }, field: "type[is_milestone]" + attribute :is_in_roadmap, label: -> { Type.human_attribute_name(:is_in_roadmap) }, field: "type[is_in_roadmap]" + attribute :allow_project_variants, + label: -> { Type.human_attribute_name(:allow_project_variants) }, + field: "type[allow_project_variants]" + attribute :default_description, + label: :"types.edit.defaults.description.label", + url: ->(type) { edit_type_defaults_path(type) }, + field: "work_package_types_forms_defaults_form_model[default_work_package_description]" + attribute :subject_pattern, + label: :"types.edit.defaults.automatically_generated_subjects.label", + url: ->(type) { edit_type_defaults_path(type) }, + field: "work_package_types_forms_defaults_form_model[subject_configuration]" + attribute :form_configuration, + label: :"types.edit.form_configuration.tab", + url: ->(type) { edit_type_form_configuration_path(type) } + attribute :workflow, label: :"types.edit.workflow.tab", url: ->(type) { edit_type_workflow_path(type) } + attribute :project_attributes, + label: :"types.edit.project_attributes.tab", + url: ->(type) { edit_type_project_attributes_path(type) } + attribute :projects, label: :"types.edit.projects.tab", url: ->(type) { edit_type_projects_path(type) } + attribute :export_configuration, + label: :"types.edit.export_configuration.tab", + url: ->(type) { edit_type_pdf_export_template_index_path(type) } + end + + kind :statuses, menu_item: :statuses, scope: -> { Status.order(:position) }, url: ->(status) { edit_status_path(status) } do + %i[name color_id default_done_ratio is_closed is_default is_readonly excluded_from_totals].each do |name| + attribute name, label: -> { Status.human_attribute_name(name) }, field: "status[#{name}]" + end + end + + kind :priorities, + menu_item: :priorities, + scope: -> { IssuePriority.order(:position) }, + url: ->(priority) { edit_admin_settings_work_package_priority_path(priority) } do + %i[name color_id active].each do |name| + attribute name, label: -> { IssuePriority.human_attribute_name(name) }, field: "enumeration[#{name}]" + end + attribute :is_default, label: :label_default, field: "enumeration[is_default]" + end + + kind :roles, + menu_item: :roles, + scope: -> { Role.where(type: %w[ProjectRole GlobalRole]).order(:builtin, :position) }, + url: ->(role) { edit_role_path(role) } do + attribute :name, label: -> { Role.human_attribute_name(:name) }, field: "role[name]" + + attributes_from do + enabled_modules = OpenProject::AccessControl.sorted_module_names(include_disabled: false) + + OpenProject::AccessControl.permissions.filter_map do |permission| + next unless enabled_modules.include?(permission.project_module.to_s) + + Settings::EntityKinds::Attribute.new( + :"permission_#{permission.name}", + label: :"permission_#{permission.name}", + group: :label_permissions, + field: "role[permissions][]=#{permission.name}", + if: ->(role) { + RequestStore.fetch([:settings_search_assignable_permissions, role.id]) do + Roles::BaseContract.new(role, User.current).assignable_permissions.map(&:name) + end.include?(permission.name) + } + ) + end + end + end + + custom_field_attributes = lambda do + visibility = ->(custom_field) { CustomFields::DetailsFieldVisibility.new(custom_field) } + label = ->(name) { -> { I18n.t("activerecord.attributes.custom_field.#{name}") } } + + attribute :name, label: :label_name, field: "custom_field[name]" + { + default_value: ->(cf) { + visibility.(cf).then { it.show_default_text_field? || it.show_default_bool_field? || it.show_default_rich_text_field? } + }, + regexp: ->(cf) { visibility.(cf).show_regex_field? }, + min_length: ->(cf) { visibility.(cf).show_min_max_length_field? }, + max_length: ->(cf) { visibility.(cf).show_min_max_length_field? }, + min_value: ->(cf) { visibility.(cf).show_min_max_value_field? }, + max_value: ->(cf) { visibility.(cf).show_min_max_value_field? }, + multi_value: ->(cf) { visibility.(cf).show_multi_value_field? }, + has_comment: ->(cf) { visibility.(cf).show_has_comment_field? }, + is_required: ->(cf) { visibility.(cf).show_is_required_field? }, + is_for_all: ->(cf) { visibility.(cf).show_is_for_all_field? }, + is_filter: ->(cf) { visibility.(cf).show_is_filter_field? }, + searchable: ->(cf) { visibility.(cf).show_is_searchable_field? }, + admin_only: ->(cf) { visibility.(cf).show_admin_only_field? }, + editable: ->(cf) { visibility.(cf).show_editable_field? }, + visible_on_user_card: ->(cf) { visibility.(cf).show_on_user_card_field? } + }.each do |name, condition| + attribute name, label: label.(name), field: "custom_field[#{name}]", if: condition + end + end + + { + work_package_custom_fields: ["WorkPackageCustomField", :work_package_custom_fields], + version_custom_fields: ["VersionCustomField", :version_custom_fields], + user_custom_fields: ["UserCustomField", :user_custom_fields_settings], + group_custom_fields: ["GroupCustomField", :group_custom_fields], + project_custom_fields: ["ProjectCustomField", :project_custom_fields_settings], + time_entry_custom_fields: ["TimeEntryCustomField", :time_entry_custom_fields] + }.each do |key, (custom_field_class, menu_item)| + kind key, + menu_item:, + scope: -> { custom_field_class.constantize.order(:position) }, + url: ->(custom_field) { url_for([:edit, :admin, :settings, custom_field]) } do + instance_exec(&custom_field_attributes) + end + end +end diff --git a/frontend/src/stimulus/controllers/highlight-setting.controller.ts b/frontend/src/stimulus/controllers/highlight-setting.controller.ts index 32938011044f..14bb1e93f172 100644 --- a/frontend/src/stimulus/controllers/highlight-setting.controller.ts +++ b/frontend/src/stimulus/controllers/highlight-setting.controller.ts @@ -27,37 +27,56 @@ import { ApplicationController } from 'stimulus-use'; +// The highlight parameter names a setting, a form field like `type[color_id]` +// or one of several inputs of a field like `role[permissions][]=edit_project`. +const HIGHLIGHT_PATTERN = /^([\w[\]]+)(?:=([\w-]+))?$/; + export default class HighlightSettingController extends ApplicationController { connect() { const url = new URL(window.location.href); - const name = url.searchParams.get('highlight'); - if (!name || !/^\w+$/.test(name)) return; + const match = HIGHLIGHT_PATTERN.exec(url.searchParams.get('highlight') ?? ''); + if (!match) return; - const container = this.findContainer(name); - if (!container) return; + const containers = this.findContainers(match[1], match[2]); + if (containers.length === 0) return; - container.classList.add('op-setting--highlighted'); - setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'center' }), 25); + containers.forEach((container) => container.classList.add('op-setting--highlighted')); + // Wait for controllers revealing parts of the page, e.g. the permissions of a role's type. + setTimeout(() => { + const visible = containers.find((container) => container.offsetParent !== null) ?? containers[0]; + visible.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); url.searchParams.delete('highlight'); window.history.replaceState(window.history.state, '', url); } - private findContainer(name:string):HTMLElement|null { + private findContainers(name:string, value:string|undefined):HTMLElement[] { const marked = document.querySelector(`[data-setting-name="${name}"]`); - if (marked) { - return marked.closest('.FormControl-checkbox-wrap, .FormControl') ?? marked; + if (marked && !value) { + return [marked.closest('.FormControl-checkbox-wrap, .FormControl') ?? marked]; + } + + const fields = this.findFields(name, value); + if (fields.length > 1 && !value) { + return [fields[0].closest('fieldset, .form--field') ?? fields[0]]; } + return fields.map((field) => field.closest('.FormControl-checkbox-wrap, .FormControl, .form--field') ?? field); + } + + private findFields(name:string, value:string|undefined):HTMLElement[] { + const valueSelector = value ? `[value="${value}"]` : ''; + const selectors = name.includes('[') + ? [`[name="${name}"]`] + : [`[name="settings[${name}]"]`, `[name^="settings[${name}]["]`]; + const fields = Array.from(document.querySelectorAll( - `[name="settings[${name}]"]:not([type="hidden"]), [name^="settings[${name}]["]:not([type="hidden"])`, + selectors.map((selector) => `${selector}${valueSelector}`).join(', '), )); - if (fields.length === 0) return null; - - if (fields.length > 1) { - return fields[0].closest('fieldset, .form--field') ?? fields[0]; - } + const visibleFields = fields.filter((field) => field.getAttribute('type') !== 'hidden'); - return fields[0].closest('.FormControl-checkbox-wrap, .FormControl, .form--field') ?? fields[0]; + // Custom inputs like the color select keep their value in a hidden field. + return visibleFields.length > 0 ? visibleFields : fields; } } diff --git a/lib/open_project/plugins/acts_as_op_engine.rb b/lib/open_project/plugins/acts_as_op_engine.rb index c64221800b7d..a12f62edef78 100644 --- a/lib/open_project/plugins/acts_as_op_engine.rb +++ b/lib/open_project/plugins/acts_as_op_engine.rb @@ -33,6 +33,7 @@ require_relative "../../../config/constants/views" require_relative "../../../config/constants/settings/definition" require_relative "../../../config/constants/settings/pages" +require_relative "../../../config/constants/settings/entity_kinds" module OpenProject::Plugins module ActsAsOpEngine diff --git a/spec/constants/settings/entity_kinds_spec.rb b/spec/constants/settings/entity_kinds_spec.rb new file mode 100644 index 000000000000..d4b054f04862 --- /dev/null +++ b/spec/constants/settings/entity_kinds_spec.rb @@ -0,0 +1,84 @@ +# 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 Settings::EntityKinds do + let(:view_context) { ApplicationController.new.tap { it.request = ActionDispatch::TestRequest.create }.view_context } + + describe Settings::EntityKinds::Kind do + subject(:kind) do + described_class.new(:statuses, menu_item: :statuses, scope: -> { Status.order(:position) }, + url: ->(status) { edit_status_path(status) }) + end + + shared_let(:open_status) { create(:status, name: "Open") } + shared_let(:in_progress) { create(:status, name: "In progress") } + + it "finds entities whose name contains any of the words" do + expect(kind.matching(%w[progr xyz])).to contain_exactly(in_progress) + end + + it "finds nothing without words" do + expect(kind.matching([])).to be_empty + end + + it "combines static and lazily built attributes" do + kind.attribute(:name, label: "Name") + kind.attributes_from { [Settings::EntityKinds::Attribute.new(:color, label: "Color")] } + + expect(kind.attributes.map(&:key)).to eq %i[name color] + end + end + + describe Settings::EntityKinds::Attribute do + let(:status) { build_stubbed(:status) } + let(:default_url) { ->(status) { edit_status_path(status) } } + + it "links to the entity's page highlighting its field" do + attribute = described_class.new(:color, label: "Color", field: "status[color_id]") + + expect(attribute.url(status, view_context, default_url)) + .to eq "#{Rails.application.routes.url_helpers.edit_status_path(status)}?highlight=status%5Bcolor_id%5D" + end + + it "links to its own page without a field" do + attribute = described_class.new(:list, label: "List", url: ->(_) { statuses_path }) + + expect(attribute.url(status, view_context, default_url)).to eq "/statuses" + end + + it "applies to the entities passing its condition", :aggregate_failures do + attribute = described_class.new(:closed, label: "Closed", if: ->(status) { status.is_closed }) + + expect(attribute.applies_to?(build_stubbed(:status, is_closed: true))).to be true + expect(attribute.applies_to?(build_stubbed(:status, is_closed: false))).to be false + end + end +end diff --git a/spec/features/admin/settings_search_spec.rb b/spec/features/admin/settings_search_spec.rb index db5653f3b89e..8a052d3c84c9 100644 --- a/spec/features/admin/settings_search_spec.rb +++ b/spec/features/admin/settings_search_spec.rb @@ -100,4 +100,30 @@ def search_for(query) expect(page).to have_no_link "Application title" end end + + context "with entities" do + shared_let(:feature) { create(:type, name: "Feature") } + shared_let(:role) { create(:project_role, name: "Developer") } + + it "finds an attribute of an entity by its name and highlights the field" do + search_for "feature color" + + within_test_selector("op-admin-settings-search") do + expect(page).to have_link "Feature" + click_on "Color" + end + + expect(page).to have_current_path edit_type_details_path(feature), wait: 20 + expect(page).to have_css(".op-setting--highlighted", text: "Color") + end + + it "finds a permission of a role and highlights its check box" do + search_for "developer edit work packages" + + within_test_selector("op-admin-settings-search") { click_on "Edit work packages" } + + expect(page).to have_current_path edit_role_path(role), wait: 20 + expect(page).to have_css(".op-setting--highlighted", text: "Edit work packages") + end + end end diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index c162d7846373..6c1cab68e813 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -39,6 +39,8 @@ subject(:tree) { described_class.new(vc_test_controller.view_context, query:, hidden_menu_items:) } + def url_helpers = Rails.application.routes.url_helpers + def leaf_labels(nodes) = nodes.flat_map { it.children.empty? ? [it.label] : leaf_labels(it.children) } def find_node(nodes, *labels) @@ -139,6 +141,67 @@ def find_node(nodes, *labels) end end + context "with entities" do + shared_let(:bug) { create(:type, name: "Bug", position: 1) } + shared_let(:feature) { create(:type, name: "Feature", position: 2) } + + it "lists the attributes of a kind, leading to the first entity" do + node = find_node(tree.nodes, "Work packages", "Types", "Color") + + expect(node.href).to eq "#{url_helpers.edit_type_details_path(bug)}?highlight=type%5Bcolor_id%5D" + end + + it "does not list the entities themselves" do + expect(find_node(tree.nodes, "Work packages", "Types").children.map(&:label)).not_to include("Bug", "Feature") + end + + context "with a query matching an entity and an attribute" do + let(:query) { "feature color" } + + it "lists the entity with the matching attribute only" do + node = find_node(tree.nodes, "Work packages", "Types", "Feature", "Color") + + expect(node.href).to eq "#{url_helpers.edit_type_details_path(feature)}?highlight=type%5Bcolor_id%5D" + expect(leaf_labels(tree.nodes)).to eq ["Color"] + end + end + + context "with a query matching an entity only" do + let(:query) { "feature" } + + it "lists all of its attributes" do + expect(find_node(tree.nodes, "Work packages", "Types", "Feature").children.map(&:label)) + .to include("Name", "Color", "Is milestone") + end + end + + context "with more matching entities than listed" do + let(:query) { "type" } + + before { (Settings::EntityKinds::MATCH_LIMIT + 1).times { create(:type, name: "Some type #{it}") } } + + it "lists only a limited number" do + types = find_node(tree.nodes, "Work packages", "Types").children.select { it.label.start_with?("Some type") } + + expect(types.size).to eq Settings::EntityKinds::MATCH_LIMIT + end + end + end + + context "with roles" do + shared_let(:role) { create(:project_role, name: "Developer") } + + let(:query) { "developer edit work packages" } + + it "groups the permissions of a role" do + node = find_node(tree.nodes, "Users and permissions", "Roles and permissions", "Developer", "Permissions", + "Edit work packages") + + expect(node.href) + .to eq "#{url_helpers.edit_role_path(role)}?highlight=role%5Bpermissions%5D%5B%5D%3Dedit_work_packages" + end + end + context "with a hidden menu item" do let(:hidden_menu_items) { ["settings_general"] } diff --git a/spec/requests/admin/settings/entity_attributes_spec.rb b/spec/requests/admin/settings/entity_attributes_spec.rb new file mode 100644 index 000000000000..f1fb43523eeb --- /dev/null +++ b/spec/requests/admin/settings/entity_attributes_spec.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. + +require "spec_helper" + +RSpec.describe "Entity attributes found by the settings search", + :settings_reset, + type: :rails_request, + with_ee: %i[readonly_work_packages work_package_subject_generation] do + shared_let(:admin) { create(:admin) } + + before { login_as(admin) } + + let(:view_context) { ApplicationController.new.tap { it.request = ActionDispatch::TestRequest.create }.view_context } + + def field_selector(field) + name, value = field.split("=", 2) + value ? "[name='#{name}'][value='#{value}']" : "[name='#{name}']" + end + + def expect_fields_rendered(kind, entity) + attributes = kind.attributes.select { it.field && it.applies_to?(entity) } + expect(attributes).not_to be_empty + + attributes.group_by { it.url(entity, view_context, kind.url).split("?").first }.each do |url, url_attributes| + get url + expect(response).to have_http_status(:ok), "for #{url}" + + url_attributes.each do |attribute| + expect(response.parsed_body.css(field_selector(attribute.field))).to be_present, + "#{attribute.field} on #{url}" + end + end + end + + { + types: -> { [create(:type)] }, + statuses: -> { [create(:status)] }, + priorities: -> { [create(:issue_priority)] }, + roles: -> { [create(:project_role), create(:global_role)] }, + work_package_custom_fields: -> { %w[string int text bool list].map { create(:work_package_custom_field, field_format: it) } }, + version_custom_fields: -> { [create(:version_custom_field, field_format: "string")] }, + user_custom_fields: -> { [create(:user_custom_field, field_format: "string")] }, + group_custom_fields: -> { [create(:group_custom_field, field_format: "string")] }, + project_custom_fields: -> { [create(:project_custom_field, field_format: "string")] }, + time_entry_custom_fields: -> { [create(:time_entry_custom_field, field_format: "string")] } + }.each do |key, entities| + it "links the attributes of #{key} to their fields", :aggregate_failures do + kind = Settings::EntityKinds.fetch(key) + + instance_exec(&entities).each { expect_fields_rendered(kind, it) } + end + end +end From 828804c7237934b7faff455068874f307df19ffe Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 16 Oct 2026 09:20:59 +0100 Subject: [PATCH 10/16] List every admin page and its actions in the settings search Admin menu items without registered settings, such as the OpenID, SAML and LDAP pages, were left out of the settings search tree. List every visible menu item, resolving menu URLs given as route helpers or belonging to an engine. Pages can now register links for their actions, e.g. adding an OpenID provider for each provider type, a LDAP connection or a storage, each shown only when its enterprise feature is available. Settings shown in a field not named "settings[]", such as the keys of the two-factor authentication, reCAPTCHA, avatars and GitHub/GitLab plugin settings, name that field, which the highlight falls back to when no "settings[]" field exists. The departments, project list and MCP pages register their settings as well, and column selections are marked for highlighting, which makes the default work package columns searchable. --- .../organization_name_component.html.erb | 2 +- .../settings/column_selection_component.rb | 1 + .../admin/settings/project_settings_form.rb | 3 +- app/models/settings/search_tree.rb | 37 ++++++-- config/constants/settings/pages.rb | 50 +++++++++- config/initializers/settings_pages.rb | 93 +++++++++++++++++++ .../highlight-setting.controller.ts | 14 ++- .../lib/open_project/auth_saml/engine.rb | 11 +++ .../lib/open_project/avatars/engine.rb | 10 ++ modules/costs/lib/costs/engine.rb | 10 ++ .../lib/open_project/documents/engine.rb | 6 ++ .../github_integration/admin/settings_form.rb | 1 + .../open_project/github_integration/engine.rb | 16 ++++ .../gitlab_integration/admin/settings_form.rb | 1 + .../open_project/gitlab_integration/engine.rb | 16 ++++ .../open_project/ldap_departments/engine.rb | 11 +++ .../lib/open_project/ldap_groups/engine.rb | 15 +++ .../lib/open_project/openid_connect/engine.rb | 16 ++++ .../lib/open_project/recaptcha/engine.rb | 15 +++ .../lib/open_project/storages/engine.rb | 16 ++++ .../two_factor_authentication/engine.rb | 17 ++++ .../lib/open_project/webhooks/engine.rb | 8 ++ .../wikis/lib/open_project/wikis/engine.rb | 11 +++ spec/constants/settings/pages_spec.rb | 19 ++++ spec/features/admin/settings_search_spec.rb | 16 ++++ spec/models/settings/search_tree_spec.rb | 42 ++++++++- .../admin/settings/registered_pages_spec.rb | 16 +++- 27 files changed, 451 insertions(+), 22 deletions(-) diff --git a/app/components/admin/departments/organization_name_component.html.erb b/app/components/admin/departments/organization_name_component.html.erb index c54eafa1e1a5..bd3ac53df88e 100644 --- a/app/components/admin/departments/organization_name_component.html.erb +++ b/app/components/admin/departments/organization_name_component.html.erb @@ -31,7 +31,7 @@ See COPYRIGHT and LICENSE files for more details. flex_layout(align_items: :center, justify_content: :space_between) do |container| container.with_column do render(Primer::Beta::Link.new(href: admin_departments_path, underline: false, color: :default)) do - render(Primer::Beta::Heading.new(tag: :h4)) { organization_name } + render(Primer::Beta::Heading.new(tag: :h4, data: { setting_name: "organization_name" })) { organization_name } end end diff --git a/app/components/admin/settings/column_selection_component.rb b/app/components/admin/settings/column_selection_component.rb index 7d34ec3ddf82..52ce3c200aab 100644 --- a/app/components/admin/settings/column_selection_component.rb +++ b/app/components/admin/settings/column_selection_component.rb @@ -43,6 +43,7 @@ def initialize(name:, columns:, selected:, protected:) def call helpers.angular_component_tag( "opce-draggable-autocompleter", + data: { setting_name: name }, inputs: { autofocus: false, options: columns, diff --git a/app/forms/admin/settings/project_settings_form.rb b/app/forms/admin/settings/project_settings_form.rb index ad25362d1f0c..4642b888256c 100644 --- a/app/forms/admin/settings/project_settings_form.rb +++ b/app/forms/admin/settings/project_settings_form.rb @@ -42,7 +42,8 @@ class ProjectSettingsForm < ApplicationForm ) end sf.fieldset_group(title: I18n.t(:setting_project_gantt_query), - description: I18n.t(:setting_project_gantt_query_text)) do |fg| + description: I18n.t(:setting_project_gantt_query_text), + data: { setting_name: "project_gantt_query" }) do |fg| fg.html_content do helpers.angular_component_tag( "opce-editable-query-props", diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index a4bf6edf1611..36882bce1f6f 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -75,21 +75,20 @@ def build(menu_node) next unless visible_menu_item?(item) children = [*page_nodes(item), *entity_kind_nodes(item), *build(item)] - next if children.empty? - Node.new(key: item.name.to_s, label: item.caption, description: nil, href: view_context.url_for(item.url), children:) + Node.new(key: item.name.to_s, label: item.caption, description: nil, href: menu_item_url(item), children:) end end def page_nodes(item) @pages_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |page| - leaves = setting_nodes(page) + leaves = [*setting_nodes(page), *link_nodes(page)] if page.label && leaves.any? [Node.new(key: page.key.to_s, label: view_context.t(page.label), description: nil, - href: view_context.url_for(page.url(@menu)), + href: page_url(page), children: leaves)] else leaves @@ -99,14 +98,40 @@ def page_nodes(item) def setting_nodes(page) page.sections.flat_map(&:visible_entries).map do |entry| - Node.new(key: "#{page.key}-#{entry.name}", + Node.new(key: "#{page.key}-#{entry.highlight}", label: entry.label(view_context), description: plain_text(entry.caption(view_context)), - href: view_context.url_for(page.url(@menu).merge(highlight: entry.name)), + href: page_url(page, highlight: entry.highlight), + children: []) + end + end + + def link_nodes(page) + page.visible_links.map do |link| + Node.new(key: "#{page.key}-#{link.key}", + label: link.label(view_context), + description: nil, + href: view_context.instance_exec(&link.url), children: []) end end + def page_url(page, **params) + url = page.url(@menu) + url.is_a?(Hash) ? view_context.url_for(url.merge(params)) : menu_item_url(page.menu_node(@menu), **params) + end + + # Mirrors Redmine::MenuManager::MenuHelper#node_url, which is private. + def menu_item_url(item, **params) + engine = item.engine ? view_context.public_send(item.engine) : view_context.main_app + + case item.url + when Hash then engine.url_for(item.url.merge(params, only_path: true)) + when Symbol then engine.public_send(item.url, **params) + else engine.url_for(item.url) + end + end + def entity_kind_nodes(item) @entity_kinds_by_menu_item.fetch(item.name, []).flat_map do |kind| attributes = kind.attributes diff --git a/config/constants/settings/pages.rb b/config/constants/settings/pages.rb index d5a5ed77b387..0f23ae803598 100644 --- a/config/constants/settings/pages.rb +++ b/config/constants/settings/pages.rb @@ -42,6 +42,13 @@ module Settings # translation. Both accept a translation key, a string or a lambda evaluated # in the view context. # + # Actions on a page, such as creating a provider, are registered as `link`s + # with a label and a `url` lambda evaluated in the view context. + # + # Settings shown in a form field named other than "settings[]", e.g. + # the keys of a plugin's settings hash, name that `field`. A setting may be + # listed several times with different fields. + # # Settings changed through something other than a "settings[]" form # field on their page (e.g. a toggle or a dialog) are registered with # `form_field: false`. The element representing them needs a @@ -56,6 +63,10 @@ module Settings # setting :welcome_title # end # end + # + # page :ldap_connections, menu_item: :ldap_authentication do + # link :new, label: :label_ldap_auth_source_new, url: -> { new_ldap_auth_source_path } + # end # end # # Settings::Pages.extend_page(:general) do @@ -63,16 +74,21 @@ module Settings # end module Pages class Entry - attr_reader :name, :condition + attr_reader :name, :field, :condition - def initialize(name, label: nil, caption: nil, form_field: true, **options) + def initialize(name, label: nil, caption: nil, field: nil, form_field: true, **options) @name = name.to_sym @label = label @caption = caption + @field = field @form_field = form_field @condition = options[:if] end + def highlight + field || name.to_s + end + def form_field? @form_field end @@ -106,6 +122,25 @@ def resolve_text(text, view_context) end end + class Link + attr_reader :key, :url + + def initialize(key, label:, url:, **options) + @key = key + @label = label + @url = url + @condition = options[:if] + end + + def label(view_context) + @label.is_a?(Proc) ? view_context.instance_exec(&@label) : view_context.t(@label) + end + + def visible? + @condition.nil? || @condition.call + end + end + class Section attr_reader :key, :entries, :condition @@ -129,7 +164,7 @@ def visible_entries end class Page - attr_reader :key, :menu_item, :tab, :label, :sections + attr_reader :key, :menu_item, :tab, :label, :sections, :links def initialize(key, menu_item:, tab: nil, label: nil, url: nil, **options) @key = key @@ -139,6 +174,7 @@ def initialize(key, menu_item:, tab: nil, label: nil, url: nil, **options) @url = url @condition = options[:if] @sections = [] + @links = [] end def visible? @@ -155,6 +191,14 @@ def section(key, **, &) section.instance_exec(&) end + def link(key, **) + @links << Link.new(key, **) + end + + def visible_links + links.select(&:visible?) + end + def entries sections.flat_map(&:entries) end diff --git a/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb index e70d768d7b6b..08fbd2f71956 100644 --- a/config/initializers/settings_pages.rb +++ b/config/initializers/settings_pages.rb @@ -129,6 +129,7 @@ setting :work_package_startdate_is_adddate setting :work_package_list_default_highlighting_mode setting :work_package_list_default_highlighted_attributes + setting :work_package_list_default_columns, form_field: false, label: :setting_column_options end page :work_packages_identifier, menu_item: :work_packages_identifier do @@ -321,4 +322,96 @@ setting :antivirus_scan_target, if: -> { Setting.antivirus_scan_mode != :disabled } setting :antivirus_scan_action, if: -> { Setting.antivirus_scan_mode != :disabled } end + + page :user_list, menu_item: :users do + link :new, label: :label_user_new, url: -> { new_user_path } + end + + page :placeholder_users, menu_item: :placeholder_users do + link :new, + label: :label_placeholder_user_new, + url: -> { new_placeholder_user_path }, + if: -> { EnterpriseToken.allows_to?(:placeholder_users) } + end + + page :groups, menu_item: :groups do + link :new, label: :label_group_new, url: -> { new_group_path } + end + + page :departments, menu_item: :departments do + setting :organization_name, form_field: false + link :new, label: :"departments.add_department", url: -> { new_department_admin_departments_path } + end + + page :workflows, menu_item: :workflows do + link :summary, label: :label_workflow_summary, url: -> { workflow_summary_types_path } + end + + page :automations, menu_item: :automations do + link :new, + label: :"automations.new", + url: -> { new_automation_path }, + if: -> { EnterpriseToken.allows_to?(:custom_actions) } + end + + page :project_phase_definitions, menu_item: :project_phase_definitions_settings do + link :new, + label: :"settings.project_phase_definitions.label_add_description", + url: -> { new_admin_settings_project_phase_definition_path }, + if: -> { EnterpriseToken.allows_to?(:customize_life_cycle) } + end + + page :project_lists, menu_item: :project_lists_settings do + setting :enabled_projects_columns, form_field: false, label: :label_columns + setting :project_gantt_query, form_field: false, caption: :setting_project_gantt_query_text + end + + page :attribute_help_texts, menu_item: :attribute_help_texts do + link :new, label: :"attribute_help_texts.add_new", url: -> { new_attribute_help_text_path(name: "WorkPackage") } + end + + page :oauth_applications, menu_item: :oauth_applications do + link :new, label: :"oauth.application.new", url: -> { new_oauth_application_path } + end + + page :ldap_connections, menu_item: :ldap_authentication do + link :new, label: :label_ldap_auth_source_new, url: -> { new_ldap_auth_source_path } + end + + page :scim_clients, menu_item: :scim_clients do + link :new, + label: :"admin.scim_clients.index.label_create_button", + url: -> { new_admin_scim_client_path }, + if: -> { EnterpriseToken.allows_to?(:scim_api) } + end + + page :mcp_configurations, menu_item: :mcp_configurations, if: -> { EnterpriseToken.allows_to?(:mcp_server) } do + setting :mcp_tool_response_format, + field: "mcp_configuration[tool_response_format]", + label: :"admin.mcp_configurations.server_form.tool_response_format", + if: -> { McpConfiguration.find_by(identifier: "mcp_server")&.enabled? } + end + + page :custom_style, menu_item: :custom_style do + %w[interface branding default_colors pdf_export_styles pdf_export_font].each do |tab| + link tab, label: :"admin.custom_styles.tab_#{tab}", url: -> { custom_style_path(tab:) } + end + link :new_color, label: :"colors.index.label_new_color", url: -> { new_color_path } + end + + page :enterprise_tokens, menu_item: :enterprise do + link :new, + label: :"admin.enterprise.create_dialog.title", + url: -> { enterprise_tokens_path } + end + + page :backups, menu_item: :backups do + link :token, + label: -> { t(Token::Backup.exists?(user: User.current) ? "backup.label_reset_token" : "backup.label_create_token") }, + url: -> { admin_backups_path } + end + + page :jira_import, menu_item: :jira_import do + link :new, label: :"admin.jira.configuration.title", url: -> { new_admin_import_jira_path } + end end diff --git a/frontend/src/stimulus/controllers/highlight-setting.controller.ts b/frontend/src/stimulus/controllers/highlight-setting.controller.ts index 14bb1e93f172..b56f59ac4c4b 100644 --- a/frontend/src/stimulus/controllers/highlight-setting.controller.ts +++ b/frontend/src/stimulus/controllers/highlight-setting.controller.ts @@ -71,12 +71,20 @@ export default class HighlightSettingController extends ApplicationController { ? [`[name="${name}"]`] : [`[name="settings[${name}]"]`, `[name^="settings[${name}]["]`]; - const fields = Array.from(document.querySelectorAll( - selectors.map((selector) => `${selector}${valueSelector}`).join(', '), - )); + let fields = this.queryFields(selectors, valueSelector); + // Some settings pages submit unscoped fields, e.g. the plugin settings of reCAPTCHA. + if (fields.length === 0 && !name.includes('[')) { + fields = this.queryFields([`[name="${name}"]`], valueSelector); + } const visibleFields = fields.filter((field) => field.getAttribute('type') !== 'hidden'); // Custom inputs like the color select keep their value in a hidden field. return visibleFields.length > 0 ? visibleFields : fields; } + + private queryFields(selectors:string[], valueSelector:string):HTMLElement[] { + return Array.from(document.querySelectorAll( + selectors.map((selector) => `${selector}${valueSelector}`).join(', '), + )); + } } diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index ac2e88b744be..b70c73be5cae 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -44,6 +44,17 @@ class Engine < ::Rails::Engine engine_name :openproject_auth_saml include OpenProject::Plugins::ActsAsOpEngine + + initializer "auth_saml.settings_search" do + ::Settings::Pages.draw do + page :saml_providers, menu_item: :plugin_saml do + link :new, + label: :"saml.providers.label_add_new", + url: -> { new_saml_provider_path }, + if: -> { EnterpriseToken.allows_to?(:sso_auth_providers) } + end + end + end extend OpenProject::Plugins::AuthPlugin register "openproject-auth_saml", diff --git a/modules/avatars/lib/open_project/avatars/engine.rb b/modules/avatars/lib/open_project/avatars/engine.rb index d711befb0e93..aee0a4a83d36 100644 --- a/modules/avatars/lib/open_project/avatars/engine.rb +++ b/modules/avatars/lib/open_project/avatars/engine.rb @@ -33,6 +33,16 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "avatars.settings_search" do + ::Settings::Pages.draw do + page :avatars, menu_item: :user_avatars do + %i[enable_gravatars enable_local_avatars].each do |name| + setting :plugin_openproject_avatars, field: "settings[#{name}]", label: :"avatars.settings.#{name}" + end + end + end + end + register "openproject-avatars", author_url: "https://www.openproject.org", settings: { diff --git a/modules/costs/lib/costs/engine.rb b/modules/costs/lib/costs/engine.rb index 94aa83a43f9f..e3b77b4868b3 100644 --- a/modules/costs/lib/costs/engine.rb +++ b/modules/costs/lib/costs/engine.rb @@ -213,6 +213,16 @@ class Engine < ::Rails::Engine setting :costs_currency setting :costs_currency_format end + + page :cost_types, menu_item: :cost_types do + link :new, label: :button_add_cost_type, url: -> { new_admin_cost_type_path } + end + + page :time_entry_activities, menu_item: :time_entry_activities do + link :new, + label: -> { "#{t(:button_add)}: #{TimeEntryActivity.model_name.human}" }, + url: -> { new_admin_settings_time_entry_activity_path } + end end end diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index 6cb606eab367..78dac42ad4df 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -119,6 +119,12 @@ class Engine < ::Rails::Engine caption: :"documents.admin.collaboration_settings.hocuspocus_server_secret.caption" end end + + page :document_types, menu_item: :document_types do + link :new, + label: -> { "#{t(:button_add)}: #{DocumentType.model_name.human}" }, + url: -> { new_admin_settings_document_type_path } + end end end diff --git a/modules/github_integration/app/forms/github_integration/admin/settings_form.rb b/modules/github_integration/app/forms/github_integration/admin/settings_form.rb index af0ae045eb10..39d7ae885442 100644 --- a/modules/github_integration/app/forms/github_integration/admin/settings_form.rb +++ b/modules/github_integration/app/forms/github_integration/admin/settings_form.rb @@ -33,6 +33,7 @@ class SettingsForm < ApplicationForm form do |f| f.autocompleter( name: :github_user_id, + wrapper_data_attributes: { setting_name: "github_user_id" }, label: I18n.t(:label_github_comment_user), caption: I18n.t(:text_github_comment_user_info), autocomplete_options: { diff --git a/modules/github_integration/lib/open_project/github_integration/engine.rb b/modules/github_integration/lib/open_project/github_integration/engine.rb index 3c0917a22f97..2c299100872c 100644 --- a/modules/github_integration/lib/open_project/github_integration/engine.rb +++ b/modules/github_integration/lib/open_project/github_integration/engine.rb @@ -34,6 +34,22 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "github_integration.settings_search" do + ::Settings::Pages.draw do + page :github_integration, menu_item: :admin_github_integration do + setting :plugin_openproject_github_integration, + field: "github_user_id", + form_field: false, + label: :label_github_comment_user, + caption: :text_github_comment_user_info + setting :plugin_openproject_github_integration, + field: "webhook_secret", + label: :label_github_webhook_secret, + caption: :text_github_webhook_secret_info + end + end + end + # TODO: webhook_secret should be marked as `secret: true`, but that is only supported # for string settings, not for keys inside this plugin settings hash. def self.settings diff --git a/modules/gitlab_integration/app/forms/gitlab_integration/admin/settings_form.rb b/modules/gitlab_integration/app/forms/gitlab_integration/admin/settings_form.rb index dfee81bb494f..37919bf50218 100644 --- a/modules/gitlab_integration/app/forms/gitlab_integration/admin/settings_form.rb +++ b/modules/gitlab_integration/app/forms/gitlab_integration/admin/settings_form.rb @@ -34,6 +34,7 @@ class SettingsForm < ApplicationForm form do |f| f.autocompleter( name: :gitlab_user_id, + wrapper_data_attributes: { setting_name: "gitlab_user_id" }, label: I18n.t(:label_gitlab_actor), caption: I18n.t(:text_gitlab_actor_info), autocomplete_options: { diff --git a/modules/gitlab_integration/lib/open_project/gitlab_integration/engine.rb b/modules/gitlab_integration/lib/open_project/gitlab_integration/engine.rb index 474ce38cdd6c..a26b285e4120 100644 --- a/modules/gitlab_integration/lib/open_project/gitlab_integration/engine.rb +++ b/modules/gitlab_integration/lib/open_project/gitlab_integration/engine.rb @@ -42,6 +42,22 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "gitlab_integration.settings_search" do + ::Settings::Pages.draw do + page :gitlab_integration, menu_item: :admin_gitlab_integration do + setting :plugin_openproject_gitlab_integration, + field: "gitlab_user_id", + form_field: false, + label: :label_gitlab_actor, + caption: :text_gitlab_actor_info + setting :plugin_openproject_gitlab_integration, + field: "webhook_secret", + label: :label_gitlab_webhook_secret, + caption: :text_gitlab_webhook_secret_info + end + end + end + # TODO: webhook_secret should be marked as `secret: true`, but that is only supported # for string settings, not for keys inside this plugin settings hash. def self.settings 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 a2c7b23f0fd3..63f3f8fa57ab 100644 --- a/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb +++ b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb @@ -33,6 +33,17 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "ldap_departments.settings_search" do + ::Settings::Pages.draw do + page :ldap_departments, menu_item: :plugin_ldap_departments do + link :new, + label: :"ldap_departments.synchronized_trees.add_new", + url: -> { new_ldap_departments_synchronized_tree_path }, + if: -> { EnterpriseToken.allows_to?(:ldap_groups) } + end + end + end + register "openproject-ldap_departments", author_url: "https://github.com/opf/openproject", bundled: true, diff --git a/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb b/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb index 9258e2fb4268..62d8542a65ba 100644 --- a/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb +++ b/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb @@ -33,6 +33,21 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "ldap_groups.settings_search" do + ::Settings::Pages.draw do + page :ldap_groups, menu_item: :plugin_ldap_groups do + link :new_filter, + label: :"ldap_groups.synchronized_filters.add_new", + url: -> { new_ldap_groups_synchronized_filter_path }, + if: -> { EnterpriseToken.allows_to?(:ldap_groups) } + link :new_group, + label: :"ldap_groups.synchronized_groups.add_new", + url: -> { new_ldap_groups_synchronized_group_path }, + if: -> { EnterpriseToken.allows_to?(:ldap_groups) } + end + end + end + register "openproject-ldap_groups", author_url: "https://github.com/opf/openproject-ldap_groups", bundled: true, diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb index 41bf4a20878a..c9a0031d66f4 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -34,6 +34,22 @@ class Engine < ::Rails::Engine engine_name :openproject_openid_connect include OpenProject::Plugins::ActsAsOpEngine + + # Provider types can only be autoloaded once the application is initialized. + config.after_initialize do + ::Settings::Pages.draw do + page :openid_connect_providers, menu_item: :plugin_openid_connect do + OpenIDConnect::Provider::OIDC_PROVIDERS.each do |type| + link :"new_#{type}", + label: -> { + "#{t('openid_connect.providers.label_add_new')}: #{t("openid_connect.providers.#{type}.name")}" + }, + url: -> { new_openid_connect_provider_path(oidc_provider: type) }, + if: -> { EnterpriseToken.allows_to?(:sso_auth_providers) } + end + end + end + end extend OpenProject::Plugins::AuthPlugin register "openproject-openid_connect", diff --git a/modules/recaptcha/lib/open_project/recaptcha/engine.rb b/modules/recaptcha/lib/open_project/recaptcha/engine.rb index 6f30356d46bb..4c5307acee07 100644 --- a/modules/recaptcha/lib/open_project/recaptcha/engine.rb +++ b/modules/recaptcha/lib/open_project/recaptcha/engine.rb @@ -36,6 +36,21 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "recaptcha.settings_search" do + ::Settings::Pages.draw do + page :recaptcha, menu_item: :plugin_recaptcha do + { + recaptcha_type: %i[recaptcha.settings.type recaptcha.settings.captcha_description_html], + website_key: %i[recaptcha.settings.website_key recaptcha.settings.website_key_text], + secret_key: %i[recaptcha.settings.secret_key recaptcha.settings.secret_key_text], + response_limit: %i[recaptcha.settings.response_limit recaptcha.settings.response_limit_text] + }.each do |field, (label, caption)| + setting :plugin_openproject_recaptcha, field: "settings[#{field}]", label:, caption: + end + end + end + end + register "openproject-recaptcha", author_url: "https://www.openproject.org", # TODO: secret_key should be marked as `secret: true`, but that is only supported diff --git a/modules/storages/lib/open_project/storages/engine.rb b/modules/storages/lib/open_project/storages/engine.rb index 83c4424e9e28..7831911559c0 100644 --- a/modules/storages/lib/open_project/storages/engine.rb +++ b/modules/storages/lib/open_project/storages/engine.rb @@ -48,6 +48,22 @@ def self.external_file_permissions # please see comments inside ActsAsOpEngine class include OpenProject::Plugins::ActsAsOpEngine + # Provider types can only be autoloaded once the application is initialized. + config.after_initialize do + ::Settings::Pages.draw do + page :storages, menu_item: :external_file_storages do + ::Storages::Storage.provider_types.each_key do |provider| + link :"new_#{provider}", + label: -> { "#{t('storages.label_add_new_storage')}: #{t("storages.provider_types.#{provider}.name")}" }, + url: -> { new_admin_settings_storage_path(provider:) }, + if: -> { + provider == "nextcloud" || EnterpriseToken.allows_to?(:one_drive_sharepoint_file_storage) + } + end + end + end + end + patches %i[WorkPackage] initializer "openproject_storages.feature_decisions" do diff --git a/modules/two_factor_authentication/lib/open_project/two_factor_authentication/engine.rb b/modules/two_factor_authentication/lib/open_project/two_factor_authentication/engine.rb index da46845fc6d0..2233b0fceafb 100644 --- a/modules/two_factor_authentication/lib/open_project/two_factor_authentication/engine.rb +++ b/modules/two_factor_authentication/lib/open_project/two_factor_authentication/engine.rb @@ -36,6 +36,23 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "two_factor_authentication.settings_search" do + ::Settings::Pages.draw do + page :two_factor_authentication, + menu_item: :two_factor_authentication, + if: -> { Setting.plugin_openproject_two_factor_authentication_writable? } do + setting :plugin_openproject_two_factor_authentication, + field: "settings[enforced]", + label: :"two_factor_authentication.settings.label_enforced", + caption: :"two_factor_authentication.settings.text_enforced" + setting :plugin_openproject_two_factor_authentication, + field: "settings[allow_remember_for_days]", + label: :"two_factor_authentication.settings.label_remember", + caption: :"two_factor_authentication.settings.text_remember" + end + end + end + register "openproject-two_factor_authentication", author_url: "https://www.openproject.org", settings: { diff --git a/modules/webhooks/lib/open_project/webhooks/engine.rb b/modules/webhooks/lib/open_project/webhooks/engine.rb index c405ac0e9469..2215e879c528 100644 --- a/modules/webhooks/lib/open_project/webhooks/engine.rb +++ b/modules/webhooks/lib/open_project/webhooks/engine.rb @@ -33,6 +33,14 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "webhooks.settings_search" do + ::Settings::Pages.draw do + page :webhooks, menu_item: :plugin_webhooks do + link :new, label: :"webhooks.outgoing.label_add_new", url: -> { new_admin_outgoing_webhook_path } + end + end + end + register "openproject-webhooks", bundled: true, author_url: "https://www.openproject.org" do diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb index 0dfceb9cf018..8aacef69570a 100644 --- a/modules/wikis/lib/open_project/wikis/engine.rb +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -37,6 +37,17 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "wikis.settings_search" do + ::Settings::Pages.draw do + page :wiki_providers, menu_item: :external_wiki_providers do + link :new, + label: :"wikis.admin.wiki_providers.label_add_new", + url: -> { new_admin_settings_wiki_provider_path }, + if: -> { EnterpriseToken.allows_to?(:xwiki_integration) } + end + end + end + initializer "openproject_wikis.inflections" do ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "XWiki" diff --git a/spec/constants/settings/pages_spec.rb b/spec/constants/settings/pages_spec.rb index 01d982e74bd6..7cdebf3eae12 100644 --- a/spec/constants/settings/pages_spec.rb +++ b/spec/constants/settings/pages_spec.rb @@ -59,6 +59,17 @@ end end + describe "#highlight" do + it "is the setting's name by default" do + expect(described_class.new(:app_title).highlight).to eq "app_title" + end + + it "is the field when given" do + expect(described_class.new(:plugin_openproject_github_integration, field: "webhook_secret").highlight) + .to eq "webhook_secret" + end + end + describe "#caption" do it "defaults to the setting's caption translation" do expect(described_class.new(:login_required).caption(view_context)).to eq I18n.t(:setting_login_required_caption) @@ -104,6 +115,14 @@ expect(tab_page.url).to eq settings_page.menu_node.url.merge(tab: "spec") end + it "lists visible links" do + settings_page.link(:shown, label: :label_user_new, url: -> { new_user_path }) + settings_page.link(:hidden, label: :label_group_new, url: -> { new_group_path }, if: -> { false }) + + expect(settings_page.visible_links.map(&:key)).to eq [:shown] + expect(settings_page.links.first.label(view_context)).to eq I18n.t(:label_user_new) + end + it "prefers a given url" do url = { controller: "/admin/settings/virus_scanning_settings", action: :show } diff --git a/spec/features/admin/settings_search_spec.rb b/spec/features/admin/settings_search_spec.rb index 8a052d3c84c9..b3c8bf088e83 100644 --- a/spec/features/admin/settings_search_spec.rb +++ b/spec/features/admin/settings_search_spec.rb @@ -56,6 +56,22 @@ def search_for(query) expect(page).to have_css(".op-setting--highlighted", text: "Host name") end + it "highlights settings submitted in fields named otherwise" do + search_for "github webhook secret" + + within_test_selector("op-admin-settings-search") { click_on "Webhook secret" } + + expect(page).to have_css(".op-setting--highlighted", text: "Webhook secret", wait: 20) + end + + it "lists actions such as adding an LDAP connection" do + search_for "ldap new" + + within_test_selector("op-admin-settings-search") { click_on "New LDAP connection" } + + expect(page).to have_current_path new_ldap_auth_source_path, wait: 20 + end + it "ignores punctuation and word order" do search_for "registration self" diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index 6c1cab68e813..7abe20c72605 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -78,12 +78,20 @@ def find_node(nodes, *labels) expect(node.description).to eq "Example: test.host" end - it "omits menu items without registered settings" do - expect(tree.nodes.map(&:label)).not_to include("Overview", "Information") + it "lists menu items without registered settings" do + node = find_node(tree.nodes, "Information") + + expect(node.href).to eq "/admin/info" + expect(node.children).to be_empty + end + + it "links menu items given as route helpers and of engines", with_flag: { wiki_enhancements: true } do + expect(find_node(tree.nodes, "Authentication", "OpenID providers").href).to eq "/admin/openid_connect/providers" + expect(find_node(tree.nodes, "Wikis", "Project wikis").href).to eq "/admin/settings/internal_wiki_provider" end it "omits the multiple versions conversion once it is done" do - expect(find_node(tree.nodes, "Work packages").children.map(&:label)).not_to include("Versions and categories") + expect(find_node(tree.nodes, "Work packages", "Versions and categories").children).to be_empty end it "describes real-time collaboration independently of its state" do @@ -93,6 +101,34 @@ def find_node(nodes, *labels) .to eq "Allows multiple users to edit a document at the same time. Requires a working Hocuspocus server." end + it "lists actions of a page" do + node = find_node(tree.nodes, "Authentication", "LDAP connections", "New LDAP connection") + + expect(node.href).to eq url_helpers.new_ldap_auth_source_path + end + + it "highlights settings shown in other fields than their own" do + node = find_node(tree.nodes, "Integrations", "GitHub", "Webhook secret") + + expect(node.href).to eq "/github_integration/admin/settings?highlight=webhook_secret" + end + + it "leads to the page creating a backup token" do + node = find_node(tree.nodes, "Backup", I18n.t("backup.label_create_token")) + + expect(node.href).to eq url_helpers.admin_backups_path + end + + context "with backups disabled", with_config: { backup_enabled: false } do + it "omits the backup page" do + expect(tree.nodes.map(&:label)).not_to include("Backup") + end + end + + it "omits actions requiring an enterprise feature that is not available" do + expect(find_node(tree.nodes, "Authentication", "OpenID providers").children).to be_empty + end + context "with the multiple versions conversion pending", with_settings: { work_package_multiple_versions: false } do it "lists the conversion below its menu item" do node = find_node(tree.nodes, "Work packages", "Versions and categories", "Multiple target versions") diff --git a/spec/requests/admin/settings/registered_pages_spec.rb b/spec/requests/admin/settings/registered_pages_spec.rb index b96442cf3fb5..713eff1842c0 100644 --- a/spec/requests/admin/settings/registered_pages_spec.rb +++ b/spec/requests/admin/settings/registered_pages_spec.rb @@ -32,7 +32,7 @@ RSpec.describe "Registered settings pages", :settings_reset, type: :rails_request, - with_ee: %i[sso_auth_providers], + with_ee: %i[sso_auth_providers mcp_server], with_flag: { llm_connection: true, ai_text_transform_actions: true } do shared_let(:admin) { create(:admin) } @@ -47,21 +47,27 @@ def rendered_setting_names .uniq end + def rendered_field_names + response.parsed_body.css("[name]").map { it["name"] }.uniq + end + def marked_setting_names - response.parsed_body.css("[data-setting-name]").map { it["data-setting-name"].to_sym }.uniq + response.parsed_body.css("[data-setting-name]").map { it["data-setting-name"] }.uniq end shared_examples "registered settings pages" do - Settings::Pages.all.each do |settings_page| + Settings::Pages.all.select { it.entries.any? }.each do |settings_page| it "lists exactly the settings rendered on the #{settings_page.key} page", :aggregate_failures do get url_for(**settings_page.url, only_path: true) entries = settings_page.sections.flat_map(&:visible_entries) form_fields, other = entries.partition(&:form_field?) + named_fields, setting_fields = form_fields.partition(&:field) expect(response).to have_http_status(:ok) - expect(rendered_setting_names).to match_array(form_fields.map(&:name)) - expect(other.map(&:name) - marked_setting_names).to be_empty + expect(rendered_setting_names).to match_array(setting_fields.map(&:name)) + expect(named_fields.map(&:field) - rendered_field_names).to be_empty + expect(other.map(&:highlight) - marked_setting_names).to be_empty end end end From 0971f5ab324356fe3f0d605ef64ea82615ad827b Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 16 Oct 2026 09:21:05 +0100 Subject: [PATCH 11/16] Find admin records like providers in the settings search Register OpenID and SAML providers, LDAP connections, synchronized LDAP filters and department trees, SCIM clients, OAuth applications, groups, project phases, Jira imports, webhooks, storages, wiki providers, document types, cost types and time tracking activities as entity kinds, so that they are found by name together with their main fields. The project wikis switch, the announcement and the MCP server are listed as single records. Kinds can be hidden by a condition, e.g. without the enterprise feature required to edit their records. --- app/models/settings/search_tree.rb | 2 +- config/constants/settings/entity_kinds.rb | 9 +- config/initializers/settings_entity_kinds.rb | 88 +++++++++++++++++++ .../lib/open_project/auth_saml/engine.rb | 37 ++++++++ modules/costs/lib/costs/engine.rb | 20 +++++ .../lib/open_project/documents/engine.rb | 12 +++ .../open_project/ldap_departments/engine.rb | 15 ++++ .../lib/open_project/ldap_groups/engine.rb | 15 ++++ .../lib/open_project/openid_connect/engine.rb | 41 +++++++++ .../lib/open_project/storages/engine.rb | 9 ++ .../lib/open_project/webhooks/engine.rb | 12 +++ .../wikis/lib/open_project/wikis/engine.rb | 21 +++++ spec/constants/settings/entity_kinds_spec.rb | 8 ++ spec/models/settings/search_tree_spec.rb | 14 +++ .../admin/settings/entity_attributes_spec.rb | 31 ++++++- 15 files changed, 329 insertions(+), 5 deletions(-) diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index 36882bce1f6f..ca3279e169da 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -133,7 +133,7 @@ def menu_item_url(item, **params) end def entity_kind_nodes(item) - @entity_kinds_by_menu_item.fetch(item.name, []).flat_map do |kind| + @entity_kinds_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |kind| attributes = kind.attributes [*attribute_nodes(kind, attributes), *matching_entity_nodes(kind, attributes)] end diff --git a/config/constants/settings/entity_kinds.rb b/config/constants/settings/entity_kinds.rb index ac305e68d4ae..3ebea1cb5d3c 100644 --- a/config/constants/settings/entity_kinds.rb +++ b/config/constants/settings/entity_kinds.rb @@ -42,6 +42,8 @@ module Settings # Labels and URLs are lambdas evaluated in the view context, URLs receiving # the entity. An `if` lambda receiving the entity limits the entities an # attribute applies to and a `group` label nests attributes below a common node. + # An `if` lambda on the kind hides it altogether, e.g. without the enterprise + # feature required to edit its entities. # # @example # Settings::EntityKinds.draw do @@ -100,16 +102,21 @@ def resolve(text, view_context) class Kind attr_reader :key, :menu_item, :url - def initialize(key, menu_item:, scope:, url:, name: :name) + def initialize(key, menu_item:, scope:, url:, name: :name, **options) @key = key @menu_item = menu_item @scope = scope @url = url @name = name + @condition = options[:if] @attributes = [] @attribute_sources = [] end + def visible? + @condition.nil? || @condition.call + end + def attribute(key, **) @attributes << Attribute.new(key, **) end diff --git a/config/initializers/settings_entity_kinds.rb b/config/initializers/settings_entity_kinds.rb index 8e39233ccd73..94e488abc342 100644 --- a/config/initializers/settings_entity_kinds.rb +++ b/config/initializers/settings_entity_kinds.rb @@ -144,4 +144,92 @@ instance_exec(&custom_field_attributes) end end + + kind :oauth_applications, + menu_item: :oauth_applications, + scope: -> { Doorkeeper::Application.without_integration.where(builtin: false).order(:name) }, + url: ->(application) { edit_oauth_application_path(application) } do + %i[name enabled redirect_uri confidential client_credentials_user_id].each do |name| + attribute name, label: :"activerecord.attributes.doorkeeper/application.#{name}", field: "application[#{name}]" + end + end + + kind :ldap_connections, + menu_item: :ldap_authentication, + scope: -> { LdapAuthSource.order(:name) }, + url: ->(connection) { edit_ldap_auth_source_path(connection) } do + %i[name host port account base_dn filter_string attr_login attr_mail].each do |name| + attribute name, label: -> { LdapAuthSource.human_attribute_name(name) }, field: "ldap_auth_source[#{name}]" + end + attribute :tls_mode, label: :"ldap_auth_sources.connection_encryption", field: "ldap_auth_source[tls_mode]" + attribute :verify_peer, label: :"ldap_auth_sources.tls_options.verify_peer", field: "ldap_auth_source[verify_peer]" + attribute :attr_admin, label: -> { LdapAuthSource.human_attribute_name(:admin) }, field: "ldap_auth_source[attr_admin]" + attribute :onthefly_register, + label: -> { LdapAuthSource.human_attribute_name(:onthefly) }, + field: "ldap_auth_source[onthefly_register]" + end + + kind :scim_clients, + menu_item: :scim_clients, + scope: -> { ScimClient.order(:name) }, + url: ->(client) { edit_admin_scim_client_path(client) }, + if: -> { EnterpriseToken.allows_to?(:scim_api) } do + %i[name auth_provider_id authentication_method].each do |name| + attribute name, label: -> { ScimClient.human_attribute_name(name) }, field: "scim_client[#{name}]" + end + end + + kind :groups, menu_item: :groups, scope: -> { Group.order(:lastname) }, name: :lastname, + url: ->(group) { edit_group_path(group) } do + attribute :name, label: -> { Group.human_attribute_name(:name) }, field: "group[lastname]" + attribute :parent, label: -> { Group.human_attribute_name(:parent) }, field: "group[parent_id]" + end + + kind :project_phase_definitions, + menu_item: :project_phase_definitions_settings, + scope: -> { Project::PhaseDefinition.order(:position) }, + url: ->(definition) { edit_admin_settings_project_phase_definition_path(definition) }, + if: -> { EnterpriseToken.allows_to?(:customize_life_cycle) } do + %i[name color_id start_gate start_gate_name finish_gate finish_gate_name].each do |name| + attribute name, + label: -> { Project::PhaseDefinition.human_attribute_name(name) }, + field: "project_phase_definition[#{name}]" + end + end + + kind :jira_imports, + menu_item: :jira_import, + scope: -> { Import::Jira.order(created_at: :desc) }, + url: ->(import) { edit_admin_import_jira_path(import) } do + %i[name url personal_access_token].each do |name| + attribute name, label: :"admin.jira.form.fields.#{name}", field: "import_jira[#{name}]" + end + end + + kind :announcement, + menu_item: :announcements, + scope: -> { Announcement.order(:id) }, + name: :text, + url: ->(_announcement) { edit_announcements_path } do + attribute :text, label: :"announcements.content", field: "announcement[text]" + attribute :show_until, label: :"announcements.show_until" + attribute :active, label: :label_active, field: "announcement[active]" + end + + kind :mcp_server, + menu_item: :mcp_configurations, + scope: -> { McpConfiguration.where(identifier: "mcp_server") }, + name: :title, + url: ->(_configuration) { mcp_configurations_path }, + if: -> { EnterpriseToken.allows_to?(:mcp_server) } do + attribute :enabled, label: -> { McpConfiguration.human_attribute_name(:enabled) }, field: "mcp_configuration[enabled]" + attribute :title, + label: -> { McpConfiguration.human_attribute_name(:title) }, + field: "mcp_configuration[title]", + if: :enabled?.to_proc + attribute :description, + label: -> { McpConfiguration.human_attribute_name(:description) }, + field: "mcp_configuration[description]", + if: :enabled?.to_proc + end end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index b70c73be5cae..5c3655ef57da 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -54,6 +54,43 @@ class Engine < ::Rails::Engine if: -> { EnterpriseToken.allows_to?(:sso_auth_providers) } end end + + ::Settings::EntityKinds.draw do + kind :saml_providers, + menu_item: :plugin_saml, + scope: -> { Saml::Provider.order(:display_name) }, + name: :display_name, + url: ->(provider) { edit_saml_provider_path(provider, edit_state: :name) }, + if: -> { EnterpriseToken.allows_to?(:sso_auth_providers) } do + { + name: %i[display_name], + metadata: %i[metadata_url], + configuration: %i[sp_entity_id idp_sso_service_url idp_slo_service_url idp_cert name_identifier_format + limit_self_registration], + encryption: %i[authn_requests_signed want_assertions_signed want_assertions_encrypted certificate] + }.each do |edit_state, names| + names.each do |name| + attribute name, + label: :"activerecord.attributes.saml/provider.#{name}", + url: ->(provider) { edit_saml_provider_path(provider, edit_state:) }, + field: "saml_provider[#{name}]" + end + end + + { + login: -> { User.human_attribute_name(:login) }, + mail: -> { User.human_attribute_name(:mail) }, + firstname: -> { User.human_attribute_name(:first_name) }, + lastname: -> { User.human_attribute_name(:last_name) }, + uid: -> { I18n.t("saml.providers.label_uid") } + }.each do |name, user_attribute| + attribute :"mapping_#{name}", + label: -> { t("label_mapping_for", attribute: user_attribute.call) }, + url: ->(provider) { edit_saml_provider_path(provider, edit_state: :mapping) }, + field: "saml_provider[mapping_#{name}]" + end + end + end end extend OpenProject::Plugins::AuthPlugin diff --git a/modules/costs/lib/costs/engine.rb b/modules/costs/lib/costs/engine.rb index e3b77b4868b3..069db3e960db 100644 --- a/modules/costs/lib/costs/engine.rb +++ b/modules/costs/lib/costs/engine.rb @@ -224,6 +224,26 @@ class Engine < ::Rails::Engine url: -> { new_admin_settings_time_entry_activity_path } end end + + ::Settings::EntityKinds.draw do + kind :cost_types, + menu_item: :cost_types, + scope: -> { CostType.order(:name) }, + url: ->(cost_type) { edit_admin_cost_type_path(cost_type) } do + %i[name unit unit_plural default for_all_projects].each do |name| + attribute name, label: -> { CostType.human_attribute_name(name) }, field: "cost_type[#{name}]" + end + end + + kind :time_entry_activities, + menu_item: :time_entry_activities, + scope: -> { TimeEntryActivity.shared.order(:position) }, + url: ->(activity) { edit_admin_settings_time_entry_activity_path(activity) } do + %i[name active].each do |name| + attribute name, label: -> { TimeEntryActivity.human_attribute_name(name) }, field: "enumeration[#{name}]" + end + end + end end activity_provider :time_entries, class_name: "Activities::TimeEntryActivityProvider", default: false diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index 78dac42ad4df..2e124a7b549c 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -126,6 +126,18 @@ class Engine < ::Rails::Engine url: -> { new_admin_settings_document_type_path } end end + + ::Settings::EntityKinds.draw do + kind :document_types, + menu_item: :document_types, + scope: -> { DocumentType.all }, + url: ->(type) { edit_admin_settings_document_type_path(type) } do + %i[name active].each do |name| + attribute name, label: -> { DocumentType.human_attribute_name(name) }, field: "enumeration[#{name}]" + end + attribute :is_default, label: :label_default, field: "enumeration[is_default]" + end + end end activity_provider :documents, class_name: "Activities::DocumentActivityProvider", default: false 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 63f3f8fa57ab..ecbf4fc702cc 100644 --- a/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb +++ b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb @@ -42,6 +42,21 @@ class Engine < ::Rails::Engine if: -> { EnterpriseToken.allows_to?(:ldap_groups) } end end + + ::Settings::EntityKinds.draw do + kind :ldap_synchronized_trees, + menu_item: :plugin_ldap_departments, + scope: -> { LdapDepartments::SynchronizedTree.order(:name) }, + url: ->(tree) { edit_ldap_departments_synchronized_tree_path(tree_id: tree.id) }, + if: -> { EnterpriseToken.allows_to?(:ldap_groups) } do + %i[name ldap_auth_source_id base_dn structure_filter_string ou_name_attribute guid_attribute user_filter_string + sync_users].each do |name| + attribute name, + label: -> { LdapDepartments::SynchronizedTree.human_attribute_name(name) }, + field: "synchronized_tree[#{name}]" + end + end + end end register "openproject-ldap_departments", diff --git a/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb b/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb index 62d8542a65ba..a378d199ffc9 100644 --- a/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb +++ b/modules/ldap_groups/lib/open_project/ldap_groups/engine.rb @@ -46,6 +46,21 @@ class Engine < ::Rails::Engine if: -> { EnterpriseToken.allows_to?(:ldap_groups) } end end + + ::Settings::EntityKinds.draw do + kind :ldap_synchronized_filters, + menu_item: :plugin_ldap_groups, + scope: -> { LdapGroups::SynchronizedFilter.order(:name) }, + url: ->(filter) { edit_ldap_groups_synchronized_filter_path(filter) }, + if: -> { EnterpriseToken.allows_to?(:ldap_groups) } do + %i[name group_name_attribute sync_users ldap_auth_source_id base_dn member_lookup_attribute + filter_string].each do |name| + attribute name, + label: -> { LdapGroups::SynchronizedFilter.human_attribute_name(name) }, + field: "synchronized_filter[#{name}]" + end + end + end end register "openproject-ldap_groups", diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb index c9a0031d66f4..096d70566e2f 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -49,6 +49,47 @@ class Engine < ::Rails::Engine end end end + + ::Settings::EntityKinds.draw do + kind :openid_connect_providers, + menu_item: :plugin_openid_connect, + scope: -> { OpenIDConnect::Provider.order(:display_name) }, + name: :display_name, + url: ->(provider) { edit_openid_connect_provider_path(provider, edit_state: :name) }, + if: -> { EnterpriseToken.allows_to?(:sso_auth_providers) } do + section_url = ->(edit_state) { ->(provider) { edit_openid_connect_provider_path(provider, edit_state:) } } + custom = ->(provider) { provider.oidc_provider == "custom" } + + attribute :display_name, + label: -> { OpenIDConnect::Provider.human_attribute_name(:display_name) }, + field: "openid_connect_provider[display_name]" + attribute :tenant, + label: -> { OpenIDConnect::Provider.human_attribute_name(:tenant) }, + field: "openid_connect_provider[tenant]", + if: ->(provider) { provider.oidc_provider == "microsoft_entra" } + attribute :metadata_url, + label: :"openid_connect.settings.endpoint_url", + url: section_url.(:metadata), + field: "openid_connect_provider[metadata_url]", + if: custom + %i[client_id client_secret post_logout_redirect_uri scope limit_self_registration].each do |name| + attribute name, + label: :"activerecord.attributes.openid_connect/provider.#{name}", + url: section_url.(:client_details), + field: "openid_connect_provider[#{name}]" + end + attribute :sync_groups, + label: -> { OpenIDConnect::Provider.human_attribute_name(:sync_groups) }, + url: section_url.(:group_mapping), + field: "openid_connect_provider[sync_groups]", + if: custom + attribute :claims, + label: -> { OpenIDConnect::Provider.human_attribute_name(:claims) }, + url: section_url.(:claims), + field: "openid_connect_provider[claims]", + if: custom + end + end end extend OpenProject::Plugins::AuthPlugin diff --git a/modules/storages/lib/open_project/storages/engine.rb b/modules/storages/lib/open_project/storages/engine.rb index 7831911559c0..b5a18db87d85 100644 --- a/modules/storages/lib/open_project/storages/engine.rb +++ b/modules/storages/lib/open_project/storages/engine.rb @@ -62,6 +62,15 @@ def self.external_file_permissions end end end + + ::Settings::EntityKinds.draw do + kind :storages, + menu_item: :external_file_storages, + scope: -> { ::Storages::Storage.order(:name) }, + url: ->(storage) { edit_admin_settings_storage_path(storage) } do + attribute :name, label: -> { ::Storages::Storage.human_attribute_name(:name) } + end + end end patches %i[WorkPackage] diff --git a/modules/webhooks/lib/open_project/webhooks/engine.rb b/modules/webhooks/lib/open_project/webhooks/engine.rb index 2215e879c528..7412f27f5e38 100644 --- a/modules/webhooks/lib/open_project/webhooks/engine.rb +++ b/modules/webhooks/lib/open_project/webhooks/engine.rb @@ -39,6 +39,18 @@ class Engine < ::Rails::Engine link :new, label: :"webhooks.outgoing.label_add_new", url: -> { new_admin_outgoing_webhook_path } end end + + ::Settings::EntityKinds.draw do + kind :webhooks, + menu_item: :plugin_webhooks, + scope: -> { ::Webhooks::Webhook.order(:name) }, + url: ->(webhook) { edit_admin_outgoing_webhook_path(webhook_id: webhook.id) } do + %i[name url description secret enabled].each do |name| + attribute name, label: -> { ::Webhooks::Webhook.human_attribute_name(name) }, field: "webhook[#{name}]" + end + attribute :events, label: -> { ::Webhooks::Webhook.human_attribute_name(:events) }, field: "webhook[events][]" + end + end end register "openproject-webhooks", diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb index 8aacef69570a..5ba43a4a1be0 100644 --- a/modules/wikis/lib/open_project/wikis/engine.rb +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -46,6 +46,27 @@ class Engine < ::Rails::Engine if: -> { EnterpriseToken.allows_to?(:xwiki_integration) } end end + + ::Settings::EntityKinds.draw do + kind :internal_wiki_provider, + menu_item: :internal_wiki_provider, + scope: -> { Wikis::InternalProvider.order(:id) }, + url: ->(_provider) { admin_settings_internal_wiki_provider_path } do + attribute :enabled, + label: :"wikis.admin.internal_provider_form.checkbox_label", + field: "wikis_internal_provider[enabled]" + end + + kind :wiki_providers, + menu_item: :external_wiki_providers, + scope: -> { Wikis::Provider.where.not(type: Wikis::InternalProvider.sti_name).order(:name) }, + url: ->(provider) { edit_admin_settings_wiki_provider_path(provider) }, + if: -> { EnterpriseToken.allows_to?(:xwiki_integration) } do + attribute :name, label: -> { Wikis::Provider.human_attribute_name(:name) } + attribute :url, label: -> { Wikis::XWikiProvider.human_attribute_name(:url) } + attribute :authentication_method, label: -> { Wikis::XWikiProvider.human_attribute_name(:authentication_method) } + end + end end initializer "openproject_wikis.inflections" do diff --git a/spec/constants/settings/entity_kinds_spec.rb b/spec/constants/settings/entity_kinds_spec.rb index d4b054f04862..f289a8cbfb18 100644 --- a/spec/constants/settings/entity_kinds_spec.rb +++ b/spec/constants/settings/entity_kinds_spec.rb @@ -55,6 +55,14 @@ expect(kind.attributes.map(&:key)).to eq %i[name color] end + + it "is hidden when its condition fails" do + hidden = described_class.new(:statuses, menu_item: :statuses, scope: -> { Status.all }, url: ->(_) { "" }, + if: -> { false }) + + expect(kind).to be_visible + expect(hidden).not_to be_visible + end end describe Settings::EntityKinds::Attribute do diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index 7abe20c72605..76fe26a70a3a 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -129,6 +129,20 @@ def find_node(nodes, *labels) expect(find_node(tree.nodes, "Authentication", "OpenID providers").children).to be_empty end + context "with the enterprise feature of OpenID providers", with_ee: %i[sso_auth_providers] do + shared_let(:provider) { create(:oidc_provider_google, display_name: "Corporate Google") } + + let(:query) { "corporate client id" } + + it "finds providers by name and their attributes" do + node = find_node(tree.nodes, "Authentication", "OpenID providers", "Corporate Google", "Client ID") + + expect(node.href) + .to eq "#{url_helpers.edit_openid_connect_provider_path(provider, edit_state: :client_details)}" \ + "&highlight=openid_connect_provider%5Bclient_id%5D" + end + end + context "with the multiple versions conversion pending", with_settings: { work_package_multiple_versions: false } do it "lists the conversion below its menu item" do node = find_node(tree.nodes, "Work packages", "Versions and categories", "Multiple target versions") diff --git a/spec/requests/admin/settings/entity_attributes_spec.rb b/spec/requests/admin/settings/entity_attributes_spec.rb index f1fb43523eeb..714e68150b28 100644 --- a/spec/requests/admin/settings/entity_attributes_spec.rb +++ b/spec/requests/admin/settings/entity_attributes_spec.rb @@ -32,7 +32,8 @@ RSpec.describe "Entity attributes found by the settings search", :settings_reset, type: :rails_request, - with_ee: %i[readonly_work_packages work_package_subject_generation] do + with_ee: %i[readonly_work_packages work_package_subject_generation sso_auth_providers scim_api + customize_life_cycle ldap_groups xwiki_integration mcp_server] do shared_let(:admin) { create(:admin) } before { login_as(admin) } @@ -44,11 +45,18 @@ def field_selector(field) value ? "[name='#{name}'][value='#{value}']" : "[name='#{name}']" end + def page_url(url) + uri = URI.parse(url) + query = Rack::Utils.parse_query(uri.query).except("highlight") + uri.query = query.to_query.presence + uri.to_s + end + def expect_fields_rendered(kind, entity) attributes = kind.attributes.select { it.field && it.applies_to?(entity) } expect(attributes).not_to be_empty - attributes.group_by { it.url(entity, view_context, kind.url).split("?").first }.each do |url, url_attributes| + attributes.group_by { page_url(it.url(entity, view_context, kind.url)) }.each do |url, url_attributes| get url expect(response).to have_http_status(:ok), "for #{url}" @@ -69,7 +77,24 @@ def expect_fields_rendered(kind, entity) user_custom_fields: -> { [create(:user_custom_field, field_format: "string")] }, group_custom_fields: -> { [create(:group_custom_field, field_format: "string")] }, project_custom_fields: -> { [create(:project_custom_field, field_format: "string")] }, - time_entry_custom_fields: -> { [create(:time_entry_custom_field, field_format: "string")] } + time_entry_custom_fields: -> { [create(:time_entry_custom_field, field_format: "string")] }, + oauth_applications: -> { [create(:oauth_application)] }, + ldap_connections: -> { [create(:ldap_auth_source)] }, + scim_clients: -> { [create(:scim_client)] }, + groups: -> { [create(:group)] }, + project_phase_definitions: -> { [create(:project_phase_definition)] }, + jira_imports: -> { [create(:jira)] }, + announcement: -> { [create(:announcement)] }, + mcp_server: -> { [create(:mcp_configuration, identifier: "mcp_server")] }, + openid_connect_providers: -> { [create(:oidc_provider), create(:oidc_provider_google)] }, + saml_providers: -> { [create(:saml_provider)] }, + ldap_synchronized_filters: -> { [create(:ldap_synchronized_filter)] }, + ldap_synchronized_trees: -> { [create(:ldap_synchronized_tree)] }, + internal_wiki_provider: -> { [create(:internal_wiki_provider)] }, + webhooks: -> { [create(:webhook)] }, + document_types: -> { [create(:document_type)] }, + cost_types: -> { [create(:cost_type)] }, + time_entry_activities: -> { [create(:time_entry_activity)] } }.each do |key, entities| it "links the attributes of #{key} to their fields", :aggregate_failures do kind = Settings::EntityKinds.fetch(key) From c2a327ca62d4fca9797a2c2a3d647b9d3ea79e2d Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 16 Oct 2026 10:40:33 +0100 Subject: [PATCH 12/16] Document the settings search Describe how the settings search builds its tree from the admin menu and the registries of settings pages and entity kinds: registering settings, tabs, links and entity attributes, and the formats understood when highlighting a setting or field on its page. Point out that the views and the registries have to be kept in sync and which drift specs ensure it. Refer to the documentation from the registry initializers. --- config/initializers/settings_entity_kinds.rb | 10 + config/initializers/settings_pages.rb | 10 + docs/development/concepts/README.md | 1 + .../concepts/settings-search/README.md | 287 ++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 docs/development/concepts/settings-search/README.md diff --git a/config/initializers/settings_entity_kinds.rb b/config/initializers/settings_entity_kinds.rb index 94e488abc342..c5817d88204f 100644 --- a/config/initializers/settings_entity_kinds.rb +++ b/config/initializers/settings_entity_kinds.rb @@ -28,6 +28,16 @@ # See COPYRIGHT and LICENSE files for more details. #++ +## +# Defines all settings for entities as structured in their respective menus. +# +# Future work: Use a generic controller, and automatically generate the UI for this based on this structure, +# so that the controllers and views, and this structure don't have to be kept in sync. +# +# Please refer to the [Settings search](docs/development/concepts/settings-search/README.md) +# for more details. +# +# See also: [settings pages](config/initializers/settings_pages.rb) Settings::EntityKinds.draw do kind :types, menu_item: :types, scope: -> { Type.order(:position) }, url: ->(type) { edit_type_details_path(type) } do attribute :name, label: -> { Type.human_attribute_name(:name) }, field: "type[name]" diff --git a/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb index 08fbd2f71956..70c92499c988 100644 --- a/config/initializers/settings_pages.rb +++ b/config/initializers/settings_pages.rb @@ -28,6 +28,16 @@ # See COPYRIGHT and LICENSE files for more details. #++ +## +# Defines all settings as structured in their respective menus, sub menus, and tabs. +# +# Future work: Use a generic controller, and automatically generate the UI for this based on this structure, +# so that the controllers and views, and this structure don't have to be kept in sync. +# +# Please refer to the [Settings search](docs/development/concepts/settings-search/README.md) +# for more details. +# +# See also: [settings entity kinds initializer](settings_entity_kinds.rb) Settings::Pages.draw do page :general, menu_item: :settings_general do setting :app_title diff --git a/docs/development/concepts/README.md b/docs/development/concepts/README.md index 784312f9d056..04f0ce98dff2 100644 --- a/docs/development/concepts/README.md +++ b/docs/development/concepts/README.md @@ -23,3 +23,4 @@ Please choose an area that you would like to read about: | [Resource changesets](resource-changesets) | How is change tracked to resources in the frontend? How to save the changes. | | [Inline editing](inline-editing) | How does inline editing and the edit field functionality work in OpenProject? | | [Queries and QuerySpace](queries) | What is the Query API concept and how is it used in the frontend? | +| [Settings search](settings-search) | How does the admin settings search find settings, actions and entities? | diff --git a/docs/development/concepts/settings-search/README.md b/docs/development/concepts/settings-search/README.md new file mode 100644 index 000000000000..e0fef77fb104 --- /dev/null +++ b/docs/development/concepts/settings-search/README.md @@ -0,0 +1,287 @@ +--- +sidebar_navigation: + title: Settings search +description: How the settings search in the administration finds settings, actions and entities, and how its registries are kept in sync with the admin pages +keywords: settings search, administration, settings pages, entity kinds, drift spec +--- + +# Development concept: Settings search + +The administration has a search above its side menu. It lists the admin menu as a tree and, below each +menu item, everything that can be found on that page: global settings, actions such as "New OAuth +application", and entities such as work package types with their attributes. Selecting a node opens the +page and, where possible, highlights the setting or field on it. + +The admin pages themselves are hand-written views. The search does not inspect them. It relies on two +registries describing what each page shows: + +| Registry | Declared in | Describes | +|---------------------------|------------------------------------------------------------------------|-----------------------------------------------------------------| +| `Settings::Pages` | `config/initializers/settings_pages.rb` and initializers of the modules | Global settings and actions (links) of each admin page | +| `Settings::EntityKinds` | `config/initializers/settings_entity_kinds.rb` and initializers of the modules | Kinds of entities (types, roles, providers, …) and their attributes | + +> [!IMPORTANT] +> The registries duplicate what the views render. Whenever you add, rename, move or remove a setting or +> form field on an admin page, update the registry as well. Two drift specs fail when the two disagree, +> see [Keeping the registries in sync](#keeping-the-registries-in-sync). + +## Overview of the code + +| File | Purpose | +|------------------------------------------------------------|----------------------------------------------------------------------| +| `config/constants/settings/pages.rb` | The `Settings::Pages` registry DSL | +| `config/constants/settings/entity_kinds.rb` | The `Settings::EntityKinds` registry DSL | +| `app/models/settings/search_tree.rb` | Builds and filters the tree from the admin menu and both registries | +| `app/components/admin/settings_search_component.rb` | The search button and overlay above the admin side menu | +| `app/components/admin/settings_search/*` | The filterable tree view and the tree fragment loaded for a query | +| `app/controllers/admin/settings_searches_controller.rb` | Serves the overlay content and the filtered tree | +| `frontend/src/stimulus/controllers/highlight-setting.controller.ts` | Highlights the element named by the `highlight` parameter | + +## How the tree is built + +`Settings::SearchTree` walks the admin menu (`Redmine::MenuManager.items(:admin_menu)`): + +- Every visible admin menu item becomes a node linking to its page, even when nothing is registered for + it. Menu items whose `if:` condition fails or which are hidden through the `hidden_menu_items` + configuration are left out together with everything below them. This is also how permissions and + feature flags apply: a page that is not in the menu is not in the search. +- Below a menu item, the tree lists the settings and links of the pages registered for it, then the + attributes and matching entities of its entity kinds, then its child menu items. +- Pages with a `label` become a node of their own, e.g. the tabs of a page. + +The search runs on the server. The query is split into words, ignoring case and punctuation, so +"self registration" finds "Self-registration". A node matches when every query word is contained in the +words of the node's label, its description (the setting's caption) or the labels of its ancestors. All +nodes below a matching node stay visible, so "passwords" lists every setting of the passwords tab. + +## Registering a settings page + +Pages are drawn with `Settings::Pages.draw`. Each page references the admin menu item it is shown under: + +```ruby +Settings::Pages.draw do + page :general, menu_item: :settings_general do + setting :app_title + setting :host_name, + caption: -> { "#{t(:label_example)}: #{request.host_with_port}" } + + setting :security_badge_displayed, if: -> { OpenProject::Configuration.security_badge_displayed? } + end + + page :mail_notifications, menu_item: :mail_notifications do + section :mails, if: -> { ActionMailer::Base.perform_deliveries } do + setting :mail_from + end + end +end +``` + +- `setting` lists a global setting shown on the page. It must have a `Settings::Definition`. +- `label:` and `caption:` default to the `setting_` and `setting__caption(_html)` + translations. Give them when the page shows a different text, so that the search shows what the user + sees. Both accept a translation key, a string or a lambda evaluated in the view context. +- `if:` on a setting, section or page hides it when the condition fails, e.g. when the page only shows a + setting in a certain configuration. Sections only exist to share such a condition. +- `url:` points to another controller than the menu item's, e.g. a tab served by its own controller. + +Modules register their pages in an initializer of their engine and may extend existing pages: + +```ruby +initializer "my_module.settings_search" do + ::Settings::Pages.draw do + page :my_module, menu_item: :my_module_settings do + setting :my_module_setting + end + end + + ::Settings::Pages.extend_page(:general) do + setting :my_plugin_setting + end +end +``` + +Registrations referencing model constants (e.g. to loop over provider types) must run in +`config.after_initialize`, as constants cannot be autoloaded while initializers run. + +### Tabs + +Pages sharing a menu item and having a `label` are listed as tabs of that menu item. The `tab` is added +to the URL as the `tab` parameter: + +```ruby +page :authentication_passwords, + menu_item: :authentication_settings, + tab: "passwords", + label: :"settings.passwords" do + setting :password_min_length +end +``` + +A tab served by a different controller gives its `url:` instead of a `tab:`. + +## Highlighting a setting on its page + +Settings link to their page with a `highlight` parameter, e.g. `/admin/settings/general?highlight=host_name`. +The `highlight-setting` Stimulus controller, attached to the `body` of every page, looks for the element to highlight, +scrolls to it and removes the parameter from the URL. It understands: + +| `highlight` value | Highlights | +|-------------------------------|----------------------------------------------------------------------------| +| `host_name` | The element with `data-setting-name="host_name"`, otherwise the `settings[host_name]` field | +| `type[color_id]` | The form field with that name | +| `role[permissions][]=edit_project` | The input of that field having the given value, e.g. one check box of a group | +| `webhook_secret` | Falls back to a field named exactly `webhook_secret` if no `settings[webhook_secret]` field exists | + +The highlight wraps the field's form control, so the label and caption are highlighted with it. + +### Settings in other fields than `settings[]` + +Some pages submit a setting in a differently named field, e.g. the keys of a plugin's settings hash or +unscoped fields. Register the field: + +```ruby +setting :plugin_openproject_github_integration, + field: "webhook_secret", + label: :label_github_webhook_secret +``` + +The same setting may be listed several times with different fields. + +### Settings without a form field + +Settings changed through a toggle, a dialog, a JavaScript component or an inline edit have no +`settings[]` field in the rendered page. Register them with `form_field: false` and mark the +element representing them in the view with `data-setting-name`: + +```ruby +setting :real_time_text_collaboration_enabled, form_field: false +``` + +```erb + + + Disable + + +``` + +In Primer forms, pass the marker as data of the input or group representing the setting: + +```ruby +sf.fieldset_group(title: I18n.t(:setting_project_gantt_query), + data: { setting_name: "project_gantt_query" }) do |fg| + # … +end +``` + +When the entry has a `field:`, the marker uses that field instead of the setting's name. + +## Actions: links to "new entity" pages + +Pages register their actions as links. A link opens its URL directly without highlighting: + +```ruby +page :oauth_applications, menu_item: :oauth_applications do + link :new, label: :"oauth.application.new", url: -> { new_oauth_application_path } +end +``` + +- `url:` is a lambda evaluated in the view context, so route helpers and parameters can be used, e.g. + one link per provider type: `new_openid_connect_provider_path(oidc_provider: type)`. +- `if:` hides the link, e.g. when the enterprise feature required by the button is not available, so + that the search does not lead to a page refusing the action. +- `label:` may be a lambda, e.g. to show "Reset backup token" instead of "Create backup token". + +Actions opening a dialog through a Turbo Stream request (e.g. "Add Enterprise token") cannot be opened +by a link. Link to the page showing the button instead. + +## Entities and their attributes + +Entities such as work package types, roles or OpenID providers are registered as entity kinds. A kind +lists its attributes below its menu item once, leading to the attribute on the first entity it applies +to. Entities themselves are only listed when their name matches a word of the query, at most +`Settings::EntityKinds::MATCH_LIMIT` per kind, so that instances with thousands of entities keep a +usable tree. "work package type bug color" therefore lists the color of the type "Bug". + +```ruby +Settings::EntityKinds.draw do + kind :types, menu_item: :types, scope: -> { Type.order(:position) }, + url: ->(type) { edit_type_details_path(type) } do + attribute :color, label: -> { Color.model_name.human }, field: "type[color_id]" + attribute :form_configuration, + label: :"types.edit.form_configuration.tab", + url: ->(type) { edit_type_form_configuration_path(type) } + end +end +``` + +- `scope:` lists the entities, `name:` names the column matched against the query (default `:name`) and + `url:` the page of an entity. +- `field:` names the form field to highlight on the entity's page, in the formats listed under + [Highlighting](#highlighting-a-setting-on-its-page). Without a field, the attribute just opens the page. +- `url:` on an attribute leads to another page or tab of the entity, e.g. the form configuration tab of + a type or a section of an OpenID provider (`edit_openid_connect_provider_path(provider, edit_state: :client_details)`). +- `if:` on an attribute receives the entity and limits the entities the attribute applies to, e.g. a + custom field's minimum and maximum length only for formats having a length. Attributes of edit pages showing + sections in display mode (e.g. storages) are registered without a field. +- `group:` nests attributes below a common node, e.g. the permissions of a role. +- `attributes_from` builds attributes lazily, e.g. one per permission. +- `if:` on the kind hides the kind altogether, e.g. without the enterprise feature required to edit its + entities. + +Single records shown like settings, such as the switch enabling project wikis +(`Wikis::InternalProvider`) or the announcement, are registered as kinds with a single entity. + +## Keeping the registries in sync + +The views and the registries must describe the same settings and fields. Nothing derives one from the +other, so they drift apart whenever a view is changed without updating the registry, or the other way +round. The search then lists settings that do not exist on the page, links to the wrong page, highlights +nothing or misses settings entirely. + +Two request specs render the admin pages and compare them with the registries. They are the safety net +for every change to an admin page and must pass: + +### `spec/requests/admin/settings/registered_pages_spec.rb` + +Renders every registered page that lists settings and checks, per page: + +- The `settings[]` fields of defined settings in the page are exactly the registered settings + without `field:` or `form_field: false`. A setting rendered but not registered fails the spec just + like a registered setting no longer rendered. +- Every registered `field:` is rendered. +- Every setting registered with `form_field: false` has its `data-setting-name` marker in the page. + +The pages are rendered twice, the second time with optional states enabled (e.g. a pending version +conversion or an enabled collaboration server), so that settings shown conditionally are checked as +well. When you add a setting that is only shown in a certain state, register it with a matching `if:` +and, if necessary, enable that state in the spec. + +### `spec/requests/admin/settings/entity_attributes_spec.rb` + +Creates entities of every kind and renders each page an attribute leads to, checking that every +registered `field:` is present there. Add an entry for every new kind with attributes that have fields. + +### What the specs do not check + +- Labels and captions. Check that the search shows the texts of the page. +- Links. Check that each link opens the intended page. +- Menu items without registered settings, which are listed automatically. + +### Checklist when changing an admin page + +1. Adding a setting to a page: register it with `setting` on that page, with `label:`/`caption:` if the + page shows other texts than the setting's translations. +2. Rendering a setting through something other than a `settings[]` field: register its `field:`, + or use `form_field: false` and add a `data-setting-name` marker. +3. Adding a button creating an entity: register a `link`, conditioned like the button. +4. Adding or renaming a field of an entity's form: update the attribute's `field:` in its kind. +5. Run both drift specs and `spec/models/settings/search_tree_spec.rb`. + +### Future work + +In the future, we should use a generic controller (which may use custom update contracts), +and generate all UI automatically based on the settings pages and entity kinds structures, +so that adding a new setting, or entity attributes only have to be registered, +and no new controllers, contracts, or templates have to be written by default. + From a6c8076c53b99ad973433f36d3c45dbba9814a6e Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:36:56 +0100 Subject: [PATCH 13/16] Cache the tree of the settings search Building the tree was expensive: every request, on every keystroke of the search, walked the admin menu, translated the labels of all settings, loaded an example entity per kind and evaluated the visibility of everything again. Building it took 35 queries on a nearly empty database, once per request. The part of the tree that is independent of the query (the menu structure, the pages and settings, the attributes of the entity kinds) is now cached per admin, locale, state of the settings, installed enterprise features and registered pages and kinds, with an expiry time covering changed entities. Only the entities matching the query are looked up per request, which is the search itself. With the cache in place, opening the search takes no queries at all after the first request, and searching takes one per entity kind. The test cache store keeps entries between examples while their records are rolled back, so the cache is cleared for every example. --- app/models/settings/search_tree.rb | 85 ++++++++++++++++--- .../concepts/settings-search/README.md | 6 ++ spec/models/settings/search_tree_spec.rb | 25 ++++++ .../clear_settings_search_tree_cache.rb | 38 +++++++++ 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 spec/support/clear_settings_search_tree_cache.rb diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index ca3279e169da..5f18b76d07ea 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -42,6 +42,14 @@ class SearchTree Node = Data.define(:key, :label, :description, :href, :children) EXAMPLE_ENTITIES = 25 + # How long the tree without the entities matching a query is cached. + # Building it is expensive (translations of all settings, an example + # entity per kind), while it is identical between requests as long as + # neither the settings nor the registries change. Entities added or + # renamed within that time are found by the search all the same; only + # the entity an attribute links to may be outdated until it expires. + TREE_CACHE_TTL = 1.minute + def self.words(text) text.to_s.downcase.split(/[^\p{L}\p{N}]+/).compact_blank end @@ -54,7 +62,9 @@ def initialize(view_context, hidden_menu_items: OpenProject::Configuration.hidden_menu_items["admin_menu"] || []) @view_context = view_context @query_words = self.class.words(query) + @pages = pages @pages_by_menu_item = pages.group_by(&:menu_item) + @entity_kinds = entity_kinds @entity_kinds_by_menu_item = entity_kinds.group_by(&:menu_item) @menu = menu @hidden_menu_items = hidden_menu_items.map(&:to_s) @@ -63,23 +73,62 @@ def initialize(view_context, attr_reader :query_words def nodes - @nodes ||= filter(build(@menu), []) + @nodes ||= filter(assemble(@menu), []) end private attr_reader :view_context - def build(menu_node) + def assemble(menu_node) menu_node.children.filter_map do |item| next unless visible_menu_item?(item) - children = [*page_nodes(item), *entity_kind_nodes(item), *build(item)] - + children = [*children_of(item), *matching_nodes(item), *assemble(item)] Node.new(key: item.name.to_s, label: item.caption, description: nil, href: menu_item_url(item), children:) end end + # The pages and entity kind attributes of a menu item, cached as they + # are expensive to build. Entities matching the query are added by + # #matching_nodes on each request. + def children_of(item) + children_by_item[item.name] || [*page_nodes(item), *kind_attribute_nodes(item)] + end + + def children_by_item + @children_by_item ||= Rails.cache.fetch(cache_key, expires_in: TREE_CACHE_TTL) { build_children_by_item } + end + + def build_children_by_item + {}.tap do |children_by_item| + each_menu_item do |item| + children_by_item[item.name] = [*page_nodes(item), *kind_attribute_nodes(item)] + end + end + end + + def each_menu_item(menu_node = @menu, &) + menu_node.children.each do |item| + next unless visible_menu_item?(item) + + yield item + each_menu_item(item, &) + end + end + + def kind_attribute_nodes(item) + @entity_kinds_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |kind| + attribute_nodes(kind, kind.attributes) + end + end + + def matching_nodes(item) + @entity_kinds_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |kind| + matching_entity_nodes(kind, kind.attributes) + end + end + def page_nodes(item) @pages_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |page| leaves = [*setting_nodes(page), *link_nodes(page)] @@ -132,13 +181,6 @@ def menu_item_url(item, **params) end end - def entity_kind_nodes(item) - @entity_kinds_by_menu_item.fetch(item.name, []).select(&:visible?).flat_map do |kind| - attributes = kind.attributes - [*attribute_nodes(kind, attributes), *matching_entity_nodes(kind, attributes)] - end - end - def matching_entity_nodes(kind, attributes) kind.matching(query_words).filter_map do |entity| children = attribute_nodes(kind, attributes, entity) @@ -211,6 +253,27 @@ def plain_text(html) view_context.strip_tags(html)&.squish.presence end + # The tree depends on the locale, the menu items the admin may see, the + # state of the settings shown only in certain configurations, the + # installed enterprise features and the registered pages and entity + # kinds. Changed entities are covered by the expiry time alone. + def cache_key + [ + "settings/search-tree", + I18n.locale, + User.current.id, + Setting.cache_key, + enterprise_tokens_key, + @hidden_menu_items.join(","), + @pages.map(&:key).join(","), + @entity_kinds.map(&:key).join(",") + ].join("/") + end + + def enterprise_tokens_key + EnterpriseToken.active_tokens.map { "#{it.id}-#{it.updated_at.to_i}" }.sort.join(",") + end + def visible_menu_item?(item) (item.condition.nil? || item.condition.call(nil)) && @hidden_menu_items.exclude?(item.name.to_s) end diff --git a/docs/development/concepts/settings-search/README.md b/docs/development/concepts/settings-search/README.md index e0fef77fb104..c3e810b2808e 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -54,6 +54,12 @@ The search runs on the server. The query is split into words, ignoring case and words of the node's label, its description (the setting's caption) or the labels of its ancestors. All nodes below a matching node stay visible, so "passwords" lists every setting of the passwords tab. +The tree without the entities matching a query (the menu structure, all settings and the attributes of +the entity kinds) is cached for every admin, locale and state of the settings, as building it is +expensive: it translates all settings and loads an example entity per kind. Only the entities matching +a query are looked up per request. Entities added or renamed are found all the same; only the entity an +attribute links to may be outdated until the cache expires (one minute). + ## Registering a settings page Pages are drawn with `Settings::Pages.draw`. Each page references the admin menu item it is shown under: diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index 76fe26a70a3a..e60ee7aee339 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -259,6 +259,31 @@ def find_node(nodes, *labels) expect(find_node(tree.nodes, "System settings").children.map(&:label)).not_to include("General") end end + + describe "caching" do + def count_queries + queries = [] + subscription = ActiveSupport::Notifications.subscribe("sql.active_record") { queries << 1 } + + yield + queries.size + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + it "serves the tree from the cache instead of building it again" do + expect(count_queries { tree.nodes }).to be > 10 + + expect(count_queries { described_class.new(vc_test_controller.view_context).nodes }).to eq(0) + end + + it "builds the tree again when a setting changed", :settings_reset do + tree.nodes + Setting.app_title = "Changed" + + expect(count_queries { described_class.new(vc_test_controller.view_context).nodes }).to be > 10 + end + end end context "as a regular user" do diff --git a/spec/support/clear_settings_search_tree_cache.rb b/spec/support/clear_settings_search_tree_cache.rb new file mode 100644 index 000000000000..c0629ab47991 --- /dev/null +++ b/spec/support/clear_settings_search_tree_cache.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; 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. +#++ + +# The settings search tree is cached across requests (see +# Settings::SearchTree). The test cache store keeps entries between +# examples, while the records they were built from are rolled back, so the +# cache has to be cleared for every example. +RSpec.configure do |config| + config.before do + Rails.cache.delete_matched(/\Asettings\/search-tree\//) + end +end From 2750506da48520bfcc283d433f2f7985ff6be0fc Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:38:45 +0100 Subject: [PATCH 14/16] Match entity names and words regardless of accents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entity names were matched without regard to case, but not without regard to accents, unlike the search for users, which is accent insensitive: searching for a role named "Réalisé" required typing the accent. The words of labels were compared the same way. Entity names are now matched through the unaccent function, and the words of the query and of labels are decomposed before they are compared, so "realise" finds "Réalisé" in either place. --- app/models/settings/search_tree.rb | 4 +++- config/constants/settings/entity_kinds.rb | 13 ++++++++++++- .../concepts/settings-search/README.md | 9 +++++---- spec/constants/settings/entity_kinds_spec.rb | 7 +++++++ spec/models/settings/search_tree_spec.rb | 17 +++++++++++++++++ 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index 5f18b76d07ea..2b592501b0ff 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -50,8 +50,10 @@ class SearchTree # the entity an attribute links to may be outdated until it expires. TREE_CACHE_TTL = 1.minute + # The words of a text, decomposed and compared without accents, as users + # may type either, like the entity names are matched. def self.words(text) - text.to_s.downcase.split(/[^\p{L}\p{N}]+/).compact_blank + text.to_s.unicode_normalize(:nfd).downcase.gsub(/\p{M}/, "").split(/[^\p{L}\p{N}]+/).compact_blank end def initialize(view_context, diff --git a/config/constants/settings/entity_kinds.rb b/config/constants/settings/entity_kinds.rb index 3ebea1cb5d3c..eb4522168367 100644 --- a/config/constants/settings/entity_kinds.rb +++ b/config/constants/settings/entity_kinds.rb @@ -137,11 +137,22 @@ def name_of(entity) entity.public_send(@name) end + # The names are matched regardless of case and accents, like the words + # of the query are compared with the words of labels (Settings::SearchTree.words). def matching(words, limit: MATCH_LIMIT) return entities.none if words.empty? column = entities.arel_table[@name] - entities.where(column.matches_any(words.map { "%#{ActiveRecord::Base.sanitize_sql_like(it)}%" })).limit(limit) + matches = words.map { |word| Arel::Nodes::Matches.new(unaccent(column), unaccent(quoted_pattern(word))) } + entities.where(Arel::Nodes::Grouping.new(Arel::Nodes::Or.new(matches))).limit(limit) + end + + def unaccent(node) + Arel::Nodes::NamedFunction.new("unaccent", [node]) + end + + def quoted_pattern(word) + Arel::Nodes.build_quoted("%#{ActiveRecord::Base.sanitize_sql_like(word)}%") end end diff --git a/docs/development/concepts/settings-search/README.md b/docs/development/concepts/settings-search/README.md index c3e810b2808e..5ff127a1375c 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -49,10 +49,11 @@ registries describing what each page shows: attributes and matching entities of its entity kinds, then its child menu items. - Pages with a `label` become a node of their own, e.g. the tabs of a page. -The search runs on the server. The query is split into words, ignoring case and punctuation, so -"self registration" finds "Self-registration". A node matches when every query word is contained in the -words of the node's label, its description (the setting's caption) or the labels of its ancestors. All -nodes below a matching node stay visible, so "passwords" lists every setting of the passwords tab. +The search runs on the server. The query is split into words, ignoring case, punctuation and accents, so +"self registration" finds "Self-registration" and "realise" finds "Réalisé". A node matches when every +query word is contained in the words of the node's label, its description (the setting's caption) or the +labels of its ancestors. All nodes below a matching node stay visible, so "passwords" lists every setting +of the passwords tab. The tree without the entities matching a query (the menu structure, all settings and the attributes of the entity kinds) is cached for every admin, locale and state of the settings, as building it is diff --git a/spec/constants/settings/entity_kinds_spec.rb b/spec/constants/settings/entity_kinds_spec.rb index f289a8cbfb18..d0ca5ed2d289 100644 --- a/spec/constants/settings/entity_kinds_spec.rb +++ b/spec/constants/settings/entity_kinds_spec.rb @@ -45,6 +45,13 @@ expect(kind.matching(%w[progr xyz])).to contain_exactly(in_progress) end + it "finds entities regardless of case and accents in the name" do + accented = create(:status, name: "Réalisé") + + expect(kind.matching(["réalisé"])).to contain_exactly(accented) + expect(kind.matching(%w[realise])).to contain_exactly(accented) + end + it "finds nothing without words" do expect(kind.matching([])).to be_empty end diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index e60ee7aee339..a02e984506d6 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -50,6 +50,13 @@ def find_node(nodes, *labels) end end + describe ".words" do + it "splits case insensitively, ignoring punctuation and accents" do + expect(described_class.words("Self-régistration")).to eq %w[self registration] + expect(described_class.words("日本語 タイトル")).to eq %w[日本語 タイトル] + end + end + context "as an admin" do current_user { admin } @@ -236,6 +243,16 @@ def find_node(nodes, *labels) expect(types.size).to eq Settings::EntityKinds::MATCH_LIMIT end end + + context "with a query ignoring accents" do + shared_let(:accented) { create(:type, name: "Réalisé", position: 3) } + + let(:query) { "realise" } + + it "finds the entity" do + expect(find_node(tree.nodes, "Work packages", "Types", "Réalisé")).to be_present + end + end end context "with roles" do From 74624d779a6e323ebb7e6040add39915988ad168 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:41:52 +0100 Subject: [PATCH 15/16] Move the collaboration setting translations to the documents module The label and caption of the real-time collaboration setting were added to the core locales, although every other string of that feature lives in the locales of the documents module, together with its settings page and its entry in the settings search. --- config/locales/en.yml | 3 --- modules/documents/config/locales/en.yml | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 497f654c140b..cdbbc5054e4b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6729,9 +6729,6 @@ en: setting_project_gantt_query: "Project portfolio Gantt view" setting_project_gantt_query_text: "You can modify the query that is used to display Gantt chart from the project overview page." setting_protocol: "Protocol" - setting_real_time_text_collaboration_enabled: "Real-time collaboration" - setting_real_time_text_collaboration_enabled_caption: > - Allows multiple users to edit a document at the same time. Requires a working Hocuspocus server. setting_registration_footer: "Registration footer" setting_registration_footer_caption: "This text is displayed in the footer of the registration page. Use the HTML editor to format the text for each selected language." setting_repositories_automatic_managed_vendor: "Automatic repository vendor type" diff --git a/modules/documents/config/locales/en.yml b/modules/documents/config/locales/en.yml index 1ffd055ed18b..7694db07f7f5 100644 --- a/modules/documents/config/locales/en.yml +++ b/modules/documents/config/locales/en.yml @@ -188,3 +188,6 @@ en: description: "An OpenProject plugin to allow creation of documents in projects." name: "OpenProject Documents" project_module_documents: "Documents" + setting_real_time_text_collaboration_enabled: "Real-time collaboration" + setting_real_time_text_collaboration_enabled_caption: > + Allows multiple users to edit a document at the same time. Requires a working Hocuspocus server. From d8bbe1b4ee0149a337226187a9f7654a82e7544f Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:58:49 +0100 Subject: [PATCH 16/16] Cut the query of the settings search at a length and word count The query parameter flowed into the search unbounded: every word was sent as an ILIKE per entity kind, so a huge or wordy query spelled expensive SQL and an unresponsive page for no search result at all. The query is now cut at 256 characters and 10 words, more than any label and its ancestors have. --- app/models/settings/search_tree.rb | 8 +++++++- docs/development/concepts/settings-search/README.md | 3 ++- spec/models/settings/search_tree_spec.rb | 8 ++++++++ spec/requests/admin/settings_search_spec.rb | 6 ++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index 2b592501b0ff..1e96909160cc 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -50,6 +50,11 @@ class SearchTree # the entity an attribute links to may be outdated until it expires. TREE_CACHE_TTL = 1.minute + # Queries are cut at this length and word count: every word must occur in + # a label anyway, and each word runs an ILIKE per entity kind. + MAX_QUERY_LENGTH = 64 + MAX_QUERY_WORDS = 5 + # The words of a text, decomposed and compared without accents, as users # may type either, like the entity names are matched. def self.words(text) @@ -63,7 +68,8 @@ def initialize(view_context, menu: Settings::Pages.admin_menu, hidden_menu_items: OpenProject::Configuration.hidden_menu_items["admin_menu"] || []) @view_context = view_context - @query_words = self.class.words(query) + @query_words = + self.class.words(query.to_s.truncate(MAX_QUERY_LENGTH, omission: "")).first(MAX_QUERY_WORDS) @pages = pages @pages_by_menu_item = pages.group_by(&:menu_item) @entity_kinds = entity_kinds diff --git a/docs/development/concepts/settings-search/README.md b/docs/development/concepts/settings-search/README.md index 5ff127a1375c..2cb51446a0fb 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -53,7 +53,8 @@ The search runs on the server. The query is split into words, ignoring case, pun "self registration" finds "Self-registration" and "realise" finds "Réalisé". A node matches when every query word is contained in the words of the node's label, its description (the setting's caption) or the labels of its ancestors. All nodes below a matching node stay visible, so "passwords" lists every setting -of the passwords tab. +of the passwords tab. The query is cut at 256 characters and 10 words, as each word runs an ILIKE per +entity kind. The tree without the entities matching a query (the menu structure, all settings and the attributes of the entity kinds) is cached for every admin, locale and state of the settings, as building it is diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index a02e984506d6..777a8fde40f3 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -164,6 +164,14 @@ def find_node(nodes, *labels) it "keeps only matching settings regardless of word order and punctuation" do expect(leaf_labels(tree.nodes)).to eq ["Self-registration"] end + + it "cuts the query at a length and word count" do + tree = described_class.new(vc_test_controller.view_context, query: "title " * 20) + + expect(tree.query_words).to eq ["title"] * described_class::MAX_QUERY_WORDS + expect(described_class.new(vc_test_controller.view_context, query: "t" * 10_000).query_words) + .to eq ["t" * described_class::MAX_QUERY_LENGTH] + end end context "with a query matching a tab" do diff --git a/spec/requests/admin/settings_search_spec.rb b/spec/requests/admin/settings_search_spec.rb index e23d168a6c00..855db49ce4cd 100644 --- a/spec/requests/admin/settings_search_spec.rb +++ b/spec/requests/admin/settings_search_spec.rb @@ -50,6 +50,12 @@ expect(response.body).to include("/admin/settings/general?highlight=app_title") expect(response.body).not_to include("highlight=host_name") end + + it "renders the tree for a huge query" do + get tree_admin_settings_search_path, params: { query: "x" * 100_000 } + + expect(response).to have_http_status(:ok) + end end context "as a regular user" do