From b37a65f1520ef07fb3668b77bab548f2bc32be8b Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:36:13 +0100 Subject: [PATCH 01/19] 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 2b0ab6eaa8aa06d567b6e3ab01cada45c0f14afd Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:37:58 +0100 Subject: [PATCH 02/19] 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 0fbb57997d02..96891a080333 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6321,6 +6321,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 013a95a237e2cfa041c3c7f20533c99f9d187398 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:37:58 +0100 Subject: [PATCH 03/19] 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 ++++++- 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 ++++++++++++++ .../forms/llm_connections/connection_form.rb | 2 +- .../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/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 96891a080333..009e300ef074 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6473,6 +6473,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/modules/llm/app/forms/llm_connections/connection_form.rb b/modules/llm/app/forms/llm_connections/connection_form.rb index 76203198f0f2..9a1dbcbe2fa2 100644 --- a/modules/llm/app/forms/llm_connections/connection_form.rb +++ b/modules/llm/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/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 3a472fc28f9b9105bb2d77e6dda4ec5d98ab8669 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:12 +0100 Subject: [PATCH 04/19] 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 d36b282a17473477808eab70991898738b484c9b Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:12 +0100 Subject: [PATCH 05/19] Add a settings search to the admin side menu Show a search field at the top of the admin side menu, below the divider, as in the side menus of other modules. While a query is entered, the menu is replaced by the tree of the matching settings below the admin menu items and tabs they are shown on. Clearing the query or pressing Escape brings the menu back. The field is focused when opening the administration. The tree is filtered on the server by 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. Without a query, no tree is built. --- app/components/_index.sass | 1 + .../settings_search/tree_component.html.erb | 43 ++++++++++ .../admin/settings_search/tree_component.rb | 63 +++++++++++++++ .../admin/settings_search_component.html.erb | 60 ++++++++++++++ .../admin/settings_search_component.rb | 42 ++++++++++ .../admin/settings_search_component.sass | 47 +++++++++++ .../admin/settings_searches_controller.rb | 40 ++++++++++ app/models/settings/search_tree.rb | 32 +++++++- app/views/layouts/admin.html.erb | 4 + app/views/layouts/base.html.erb | 1 + config/locales/en.yml | 4 + config/routes.rb | 4 + .../admin/settings-search.controller.ts | 47 +++++++++++ .../settings_search/tree_component_spec.rb | 78 +++++++++++++++++++ .../admin/settings_search_component_spec.rb | 67 ++++++++++++++++ spec/models/settings/search_tree_spec.rb | 45 ++++++++++- spec/requests/admin/settings_search_spec.rb | 74 ++++++++++++++++++ 17 files changed, 650 insertions(+), 2 deletions(-) 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 frontend/src/stimulus/controllers/dynamic/admin/settings-search.controller.ts 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 941bb5a80ae2..365d9aafc210 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..616485c45971 --- /dev/null +++ b/app/components/admin/settings_search/tree_component.html.erb @@ -0,0 +1,43 @@ +<%#-- 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::Alpha::TreeView.new( + node_variant: :anchor, + data: { target: "filterable-tree-view.treeViewList" } + ) + ) do |tree_view| %> + <% if @tree.query_words.any? %> + <% 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 %> +<% 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..cfbe285c15ad --- /dev/null +++ b/app/components/admin/settings_search/tree_component.rb @@ -0,0 +1,63 @@ +# 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 + include SearchHighlightingHelper + + def initialize(tree:) + super() + @tree = tree + end + + private + + def add_node(parent, node) + if node.children.any? + parent.with_sub_tree(label: label(node), href: node.href, expanded: true, select_variant: :none, + data: node_data(node)) do |sub_tree| + node.children.each { add_node(sub_tree, it) } + end + else + parent.with_leaf(label: label(node), href: node.href, select_variant: :none, data: node_data(node)) + end + end + + def label(node) + highlight_text_by_terms(node.label, @tree.query_words) + 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..da3eb0b12c3f --- /dev/null +++ b/app/components/admin/settings_search_component.html.erb @@ -0,0 +1,60 @@ +<%#-- 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( + classes: "op-admin-settings-search", + data: { controller: "admin--settings-search", test_selector: "op-admin-settings-search" } + ) + ) do %> + <%= render( + Primer::OpenProject::FilterableTreeView.new( + src: helpers.tree_admin_settings_search_path, + classes: "op-sidemenu-filterable-tree", + show_search_highlighting: false, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: { hidden: true }, + filter_input_arguments: { + name: :settings_search_query, + label: t("admin.settings_search.title"), + type: :text, + show_clear_button: false, + autofocus: @autofocus, + autocomplete: "off", + placeholder: t("admin.settings_search.filter_placeholder"), + data: { + test_selector: "op-admin-settings-search--filter", + admin__settings_search_target: "input", + action: "input->admin--settings-search#update keydown.esc->admin--settings-search#clear" + } + }, + no_results_node_arguments: { label: t("admin.settings_search.no_results") } + ) + ) %> +<% 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..0e86e796f47e --- /dev/null +++ b/app/components/admin/settings_search_component.rb @@ -0,0 +1,42 @@ +# 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 + def initialize(autofocus: false) + super() + @autofocus = autofocus + end + + 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..fc3d0dd12729 --- /dev/null +++ b/app/components/admin/settings_search_component.sass @@ -0,0 +1,47 @@ +//-- 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 + display: flex + flex-direction: column + flex-shrink: 0 + min-height: 0 + margin-bottom: var(--base-size-8) + + filterable-tree-view + flex: 1 + min-height: 0 + + &[data-searching] + flex: 1 + + &:not([data-searching]) .FilterableTreeViewTreeContainer + display: none + +#menu-sidebar:has(.op-admin-settings-search[data-searching]) .menu_root + display: none diff --git a/app/controllers/admin/settings_searches_controller.rb b/app/controllers/admin/settings_searches_controller.rb new file mode 100644 index 000000000000..3ffb371cbdae --- /dev/null +++ b/app/controllers/admin/settings_searches_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + class SettingsSearchesController < ApplicationController + before_action :require_admin + + 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/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index ea911add2e86..698d20a5fa06 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(autofocus: current_page?(admin_index_path))) %> +<% 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 1f90a8e510a0..789ed1689610 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -120,6 +120,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 009e300ef074..fef8864e89e6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2186,6 +2186,10 @@ 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: + 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 9811b594d267..bab8a9782697 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -898,6 +898,10 @@ end namespace :admin do + resource :settings_search, only: [] do + get :tree + end + 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/frontend/src/stimulus/controllers/dynamic/admin/settings-search.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/settings-search.controller.ts new file mode 100644 index 000000000000..22a0d91240b4 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/settings-search.controller.ts @@ -0,0 +1,47 @@ +//-- 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 SettingsSearchController extends ApplicationController { + static targets = ['input']; + + declare readonly inputTarget:HTMLInputElement; + + connect() { + this.update(); + } + + update() { + this.element.toggleAttribute('data-searching', this.inputTarget.value.trim().length > 0); + } + + clear() { + this.inputTarget.value = ''; + this.inputTarget.dispatchEvent(new Event('input', { bubbles: true })); + } +} 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..a0ec002dad68 --- /dev/null +++ b/spec/components/admin/settings_search/tree_component_spec.rb @@ -0,0 +1,78 @@ +# 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(:query_words) { ["title"] } + let(:tree) { instance_double(Settings::SearchTree, nodes:, query_words:) } + + 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 "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 + + context "without a query" do + let(:query_words) { [] } + + it "renders an empty tree without building it", :aggregate_failures do + expect(rendered_component).to have_no_link "System settings", visible: :all + expect(rendered_component).to have_no_text "No settings found" + expect(tree).not_to have_received(:nodes) + end + end + + 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/components/admin/settings_search_component_spec.rb b/spec/components/admin/settings_search_component_spec.rb new file mode 100644 index 000000000000..48811d6724c9 --- /dev/null +++ b/spec/components/admin/settings_search_component_spec.rb @@ -0,0 +1,67 @@ +# 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 + let(:autofocus) { false } + + subject(:rendered_component) do + render_inline(described_class.new(autofocus:)) + page + end + + context "as an admin" do + current_user { build_stubbed(:admin) } + + it "renders a search field loading the settings tree from the server", :aggregate_failures do + expect(rendered_component).to have_field "Search settings" + expect(rendered_component).to have_no_css("input[autofocus]") + expect(rendered_component).to have_no_css(".FormControl-input-trailingAction") + expect(rendered_component).to have_css("filterable-tree-view[src='/admin/settings_search/tree']") + expect(rendered_component).to have_field(id: "settings_search_query") + end + + context "with autofocus" do + let(:autofocus) { true } + + it "focuses the search field" do + expect(rendered_component).to have_css("input[autofocus]") + end + 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/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 new file mode 100644 index 000000000000..b6bd912490b7 --- /dev/null +++ b/spec/requests/admin/settings_search_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. + +require "spec_helper" + +RSpec.describe "Admin settings search", type: :rails_request do + before { login_as(user) } + + context "as an admin" do + let(:user) { create(:admin) } + + it "renders the search field in the admin sidebar, focused on the overview", :aggregate_failures do + get admin_index_path + + search = response.parsed_body.at_css("#menu-sidebar [data-test-selector='op-admin-settings-search']") + expect(search.at_css("filterable-tree-view")["src"]).to eq tree_admin_settings_search_path + expect(search.at_css("input[autofocus]")).to be_present + + get admin_settings_general_path + + expect(response.parsed_body.at_css("[data-test-selector='op-admin-settings-search'] input[autofocus]")).to be_nil + end + + it "renders an empty tree without a query" do + get tree_admin_settings_search_path + + expect(response.parsed_body.css("a")).to be_empty + 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.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 + get tree_admin_settings_search_path + + expect(response).to have_http_status(:forbidden) + end + end +end From 7ad5aea6cda6c54443440e47953b9aab8d6d6648 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:39:19 +0100 Subject: [PATCH 06/19] 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 | 131 ++++++++++++++++++ 5 files changed, 209 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 fc3d0dd12729..dd7349928765 100644 --- a/app/components/admin/settings_search_component.sass +++ b/app/components/admin/settings_search_component.sass @@ -45,3 +45,14 @@ #menu-sidebar:has(.op-admin-settings-search[data-searching]) .menu_root display: none + +@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 77db11f24adf..996cb2185fcc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -289,7 +289,8 @@ def body_data_attributes(local_assigns) service_worker = pwa_service_worker_attributes { - 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", service_worker[:controller], stimulus_body_controller].compact.join(" "), relative_url_root: root_path, 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 9b5df02e6c6d..40e3889d112d 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -76,6 +76,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'; @@ -128,6 +129,7 @@ OpenProjectStimulusApplication.preregister('pwa-service-worker', PwaServiceWorke 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..d5713178a2cd --- /dev/null +++ b/spec/features/admin/settings_search_spec.rb @@ -0,0 +1,131 @@ +# 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 + end + + # Typing character by character fetches the tree for every prefix, and a late response may replace + # the tree while a result is being clicked. Entering the query at once only fetches the final tree. + def search_for(query) + expect(page).to have_css(".op-admin-settings-search filterable-tree-view[data-catalyst]", wait: 20) + input = find_test_selector("op-admin-settings-search--filter") + page.execute_script(<<~JS, input, query) + arguments[0].value = arguments[1]; + arguments[0].dispatchEvent(new Event("input", { bubbles: true })); + JS + expect(page).to have_css(".op-admin-settings-search filterable-tree-view a", wait: 20) + end + + it "shows the results instead of the admin menu while searching" do + within("#menu-sidebar") do + expect(page).to have_link "Date and time formats" + + search_for "host name" + + expect(page).to have_link "Host name" + expect(page).to have_no_link "Date and time formats" + + find_test_selector("op-admin-settings-search--filter").send_keys(:escape) + + expect(page).to have_link "Date and time formats" + expect(page).to have_no_link "Host name" + end + end + + it "focuses the search on the admin overview" do + visit admin_index_path + + expect(page).to have_field "Search settings", focused: true + 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 01e1c1c7cb35e158003010e37de5c597bc9b4eeb Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:49:04 +0100 Subject: [PATCH 07/19] 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 6d52846102b767d5ec50f183e9a77a3c414f5fa1 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Wed, 14 Oct 2026 23:49:04 +0100 Subject: [PATCH 08/19] 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 d5713178a2cd..bb4c85dc5d58 100644 --- a/spec/features/admin/settings_search_spec.rb +++ b/spec/features/admin/settings_search_spec.rb @@ -128,4 +128,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 78778f3edcec3a6eb50c4ca55067ae9219f9c3d9 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 16 Oct 2026 09:20:59 +0100 Subject: [PATCH 09/19] 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..af617f53225d 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].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 bb4c85dc5d58..4ccbb17d6a20 100644 --- a/spec/features/admin/settings_search_spec.rb +++ b/spec/features/admin/settings_search_spec.rb @@ -84,6 +84,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 b7991538c1fc1a8dc61ac242d6138be9a69bb891 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 16 Oct 2026 09:21:05 +0100 Subject: [PATCH 10/19] 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 ee31d76a7e4e783085005824dacb44cd3e559cf5 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 16 Oct 2026 10:40:33 +0100 Subject: [PATCH 11/19] 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 | 290 ++++++++++++++++++ 4 files changed, 311 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 af617f53225d..546f738249c1 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..f1c46b1c2870 --- /dev/null +++ b/docs/development/concepts/settings-search/README.md @@ -0,0 +1,290 @@ +--- +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 field at the top of its side menu, focused when opening the +administration. While a query is entered, the side menu is replaced by the results: 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. Clearing +the query or pressing Escape brings the menu back. + +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 field at the top of the admin side menu, showing the results instead of the menu | +| `app/components/admin/settings_search/tree_component.rb` | The tree fragment loaded for a query | +| `frontend/src/stimulus/controllers/dynamic/admin/settings-search.controller.ts` | Swaps the side menu for the results while a query is entered | +| `app/controllers/admin/settings_searches_controller.rb` | Serves the tree filtered by a query | +| `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 f8ecbcd750061a8297f64e0562127ecff7f9ca69 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:36:56 +0100 Subject: [PATCH 12/19] 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 f1c46b1c2870..ba1b50005209 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -57,6 +57,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 72b6f634e24b0061ae6b434e5efc0ab6300b3c53 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:38:45 +0100 Subject: [PATCH 13/19] 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 ba1b50005209..c0783197db17 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -52,10 +52,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 b4a7e481f0edf0cb156ce351f0dea046a846b388 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:41:52 +0100 Subject: [PATCH 14/19] 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 fef8864e89e6..aa26b82bf1b1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6477,9 +6477,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 40433ffac5f65d4790a9d58727e1bc1287ecc0c2 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 19 Oct 2026 22:58:49 +0100 Subject: [PATCH 15/19] 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 c0783197db17..b03ab8719c0a 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -56,7 +56,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 64 characters and 5 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 b6bd912490b7..542c801b1cc1 100644 --- a/spec/requests/admin/settings_search_spec.rb +++ b/spec/requests/admin/settings_search_spec.rb @@ -60,6 +60,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 From 53cd68655cb24331964304d64a4ccc9cc79fbe8d Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 23 Oct 2026 12:51:23 +0100 Subject: [PATCH 16/19] Show why a setting was found by its description Settings found because the query matches their description, not their name, were listed without any highlighted word, so it was unclear why they appeared. Show an excerpt of the description below the label of such a setting, around the words found there, with those words highlighted. Turn line and paragraph breaks into spaces when stripping a caption's markup, so that sentences separated by them are not joined. --- .../admin/settings_search/tree_component.rb | 7 ++- .../admin/settings_search_component.sass | 5 ++ app/models/settings/search_tree.rb | 49 +++++++++++++++++-- .../concepts/settings-search/README.md | 5 +- .../settings_search/tree_component_spec.rb | 14 +++++- spec/features/admin/settings_search_spec.rb | 3 +- spec/models/settings/search_tree_spec.rb | 25 ++++++++++ 7 files changed, 100 insertions(+), 8 deletions(-) diff --git a/app/components/admin/settings_search/tree_component.rb b/app/components/admin/settings_search/tree_component.rb index cfbe285c15ad..1e601a5cdac2 100644 --- a/app/components/admin/settings_search/tree_component.rb +++ b/app/components/admin/settings_search/tree_component.rb @@ -52,7 +52,12 @@ def add_node(parent, node) end def label(node) - highlight_text_by_terms(node.label, @tree.query_words) + label = highlight_text_by_terms(node.label, @tree.query_words) + excerpt = @tree.excerpt(node) + return label unless excerpt + + safe_join([label, content_tag(:span, highlight_text_by_terms(excerpt, @tree.query_words), + class: "op-admin-settings-search--excerpt")]) end def node_data(node) diff --git a/app/components/admin/settings_search_component.sass b/app/components/admin/settings_search_component.sass index dd7349928765..7a37ac8c9825 100644 --- a/app/components/admin/settings_search_component.sass +++ b/app/components/admin/settings_search_component.sass @@ -43,6 +43,11 @@ &:not([data-searching]) .FilterableTreeViewTreeContainer display: none + &--excerpt + display: block + font-size: var(--text-body-size-small) + opacity: 0.8 + #menu-sidebar:has(.op-admin-settings-search[data-searching]) .menu_root display: none diff --git a/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb index 1e96909160cc..8643d7d0530e 100644 --- a/app/models/settings/search_tree.rb +++ b/app/models/settings/search_tree.rb @@ -38,6 +38,9 @@ module Settings # # 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. + # + # Nodes found by their description get an excerpt of it, showing why they + # were found. class SearchTree Node = Data.define(:key, :label, :description, :href, :children) EXAMPLE_ENTITIES = 25 @@ -55,6 +58,9 @@ class SearchTree MAX_QUERY_LENGTH = 64 MAX_QUERY_WORDS = 5 + EXCERPT_LENGTH = 80 + EXCERPT_LEADING_CONTEXT = 20 + # 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) @@ -84,6 +90,11 @@ def nodes @nodes ||= filter(assemble(@menu), []) end + def excerpt(node) + nodes + excerpts[node.key] + end + private attr_reader :view_context @@ -246,19 +257,51 @@ def filter(nodes, ancestor_words) 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)]) + next node if matches?(path_words) || matches_by_description?(node, path_words) children = filter(node.children, path_words) node.with(children:) if children.any? end end + def matches_by_description?(node, path_words) + return false unless matches?([*path_words, *self.class.words(node.description)]) + + excerpts[node.key] = excerpt_of(node.description, unmatched_query_words(path_words)) + true + end + def matches?(words) - query_words.all? { |query_word| words.any? { it.include?(query_word) } } + unmatched_query_words(words).empty? + end + + def unmatched_query_words(words) + query_words.reject { |query_word| words.any? { it.include?(query_word) } } + end + + def excerpts + @excerpts ||= {} + end + + def excerpt_of(text, words) + return text if text.length <= EXCERPT_LENGTH + + position = first_word_position(text, words) + start = position > EXCERPT_LEADING_CONTEXT ? text.rindex(" ", position - EXCERPT_LEADING_CONTEXT).to_i : 0 + excerpt = text[start..].strip.truncate(EXCERPT_LENGTH, separator: " ", omission: "…") + start.positive? ? "…#{excerpt}" : excerpt + end + + def first_word_position(text, words) + text.scan(/[\p{L}\p{N}]+/) do + match = Regexp.last_match + return match.begin(0) if self.class.words(match[0]).any? { |word| words.any? { word.include?(it) } } + end + 0 end def plain_text(html) - view_context.strip_tags(html)&.squish.presence + view_context.strip_tags(html.to_s.gsub(%r{|

}i, " ")).squish.presence end # The tree depends on the locale, the menu items the admin may see, the diff --git a/docs/development/concepts/settings-search/README.md b/docs/development/concepts/settings-search/README.md index b03ab8719c0a..e36f1f60ce0f 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -56,8 +56,9 @@ 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. The query is cut at 64 characters and 5 words, as each word runs an ILIKE per -entity kind. +of the passwords tab. A node found only through its description shows an excerpt of it below its label, +around the words found there, so that it is clear why it is listed. The query is cut at 64 characters +and 5 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/components/admin/settings_search/tree_component_spec.rb b/spec/components/admin/settings_search/tree_component_spec.rb index a0ec002dad68..53b6f4962fb6 100644 --- a/spec/components/admin/settings_search/tree_component_spec.rb +++ b/spec/components/admin/settings_search/tree_component_spec.rb @@ -40,7 +40,8 @@ ] end let(:query_words) { ["title"] } - let(:tree) { instance_double(Settings::SearchTree, nodes:, query_words:) } + let(:excerpt) { nil } + let(:tree) { instance_double(Settings::SearchTree, nodes:, query_words:, excerpt:) } subject(:rendered_component) do render_inline(described_class.new(tree:)) @@ -58,6 +59,17 @@ expect(rendered_component).to have_css("[aria-expanded='true']", text: "System settings") end + context "with an excerpt of a description" do + let(:query_words) { ["header"] } + let(:excerpt) { "Shown in the header" } + + it "shows it below the label, highlighting the query", :aggregate_failures do + expect(rendered_component).to have_css(".op-admin-settings-search--excerpt", text: "Shown in the header", visible: :all) + expect(rendered_component).to have_css(".op-admin-settings-search--excerpt .op-search-highlight", text: "header", + visible: :all) + end + end + context "without a query" do let(:query_words) { [] } diff --git a/spec/features/admin/settings_search_spec.rb b/spec/features/admin/settings_search_spec.rb index 4ccbb17d6a20..7aebf95000e5 100644 --- a/spec/features/admin/settings_search_spec.rb +++ b/spec/features/admin/settings_search_spec.rb @@ -136,11 +136,12 @@ def search_for(query) expect(page).to have_css(".op-setting--highlighted[data-setting-name='ai_text_transform_actions_enabled']") end - it "finds settings by their description" do + it "finds settings by their description and shows an excerpt of it" 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_css(".op-admin-settings-search--excerpt", text: "Multiple values allowed (comma separated)") expect(page).to have_no_link "Application title" end end diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index 777a8fde40f3..0d39264b8e9d 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -196,6 +196,31 @@ def find_node(nodes, *labels) it "keeps the described settings" do expect(leaf_labels(tree.nodes)).to include("Objects per page options") end + + it "explains the match with an excerpt of the description" do + node = find_node(tree.nodes, "System settings", "General", "Objects per page options") + + expect(tree.excerpt(node)).to start_with "Multiple values allowed (comma separated). Note:" + end + end + + context "with a query matching a long description" do + let(:query) { "admin info" } + + it "excerpts the description around the match", :aggregate_failures do + excerpt = tree.excerpt(find_node(tree.nodes, "System settings", "General", "Display security badge")) + + expect(excerpt).to start_with("…").and include("Information administration panel") + expect(excerpt.length).to be <= Settings::SearchTree::EXCERPT_LENGTH + 1 + end + end + + context "with a query matching a label" do + let(:query) { "host name" } + + it "has no excerpt" do + expect(tree.excerpt(find_node(tree.nodes, "System settings", "General", "Host name"))).to be_nil + end end context "with a query matching nothing" do From c5c813756acce53c9b6ca1942911b039f7eb0381 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Fri, 23 Oct 2026 15:09:37 +0100 Subject: [PATCH 17/19] fix border radius for setting highlight box --- .../admin/settings_search_component.sass | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/components/admin/settings_search_component.sass b/app/components/admin/settings_search_component.sass index 7a37ac8c9825..ba9ced779643 100644 --- a/app/components/admin/settings_search_component.sass +++ b/app/components/admin/settings_search_component.sass @@ -53,11 +53,18 @@ @keyframes op-setting-highlight-fade to - background-color: transparent - box-shadow: none + opacity: 0 .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 + position: relative + isolation: isolate + + &::before + content: "" + position: absolute + inset: calc(-1 * var(--base-size-8)) + z-index: -1 + border-radius: var(--borderRadius-medium) + background-color: var(--bgColor-attention-muted) + pointer-events: none + animation: op-setting-highlight-fade 1s ease-out 3s forwards From 53bdbe66a5e8a8fceadcbb35b6e8b9ddd5d44b75 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 26 Oct 2026 12:19:23 +0000 Subject: [PATCH 18/19] Check that settings are registered with the settings search The registries of the settings search are kept by hand, so settings added later may be missing from the search. Guard against that: - Every setting that can be changed is either registered on a settings page or declared with `not_in_administration`, e.g. because it is configured through the environment. Feature flags are left out. - The page of every admin menu item is rendered and fails the spec for any setting field or marker not registered for that menu item, which catches settings on admin pages that are not registered at all. - Danger warns on pull requests changing admin views, forms or components without changing a registry, as new fields of entity forms cannot be told apart from ones deliberately not listed. No setting added since the registries were written was missing. --- .../dangerfiles/settings_search/Dangerfile | 84 ++++++++++++++++++ Dangerfile | 1 + config/constants/settings/pages.rb | 13 +++ config/initializers/settings_pages.rb | 35 ++++++++ .../concepts/settings-search/README.md | 24 +++++- spec/constants/settings/pages_spec.rb | 19 ++++ .../admin/settings/admin_pages_spec.rb | 86 +++++++++++++++++++ 7 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 .github/dangerfiles/settings_search/Dangerfile create mode 100644 spec/requests/admin/settings/admin_pages_spec.rb diff --git a/.github/dangerfiles/settings_search/Dangerfile b/.github/dangerfiles/settings_search/Dangerfile new file mode 100644 index 000000000000..1e29fe5d6d1b --- /dev/null +++ b/.github/dangerfiles/settings_search/Dangerfile @@ -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. +#++ + +SETTINGS_SEARCH_REGISTRIES = %w[ + config/initializers/settings_pages.rb + config/initializers/settings_entity_kinds.rb +].freeze +MODULE_ENGINE_REGEX = %r{\Amodules/[^/]+/lib/.+/engine\.rb\z} +CORE_ADMIN_UI_DIRECTORIES = %w[admin custom_fields work_package_types types statuses roles groups ldap_auth_sources oauth].freeze +CORE_ADMIN_UI_REGEX = %r{\Aapp/(views|forms|components)/(.+/)?(#{CORE_ADMIN_UI_DIRECTORIES.join('|')})/} +MODULE_UI_REGEX = %r{\A(modules/[^/]+)/app/(views|forms|components)/} +SETTINGS_SEARCH_UI_REGEX = %r{\Aapp/components/admin/settings_search} +SETTINGS_SEARCH_DOCS_URL = + "https://github.com/opf/openproject/blob/dev/docs/development/concepts/settings-search/README.md" \ + "#checklist-when-changing-an-admin-page" + +def registers_with_settings_search?(module_dir) + Dir.glob("#{module_dir}/lib/**/engine.rb").any? do |engine| + File.read(engine).match?(/Settings::(Pages|EntityKinds)\.draw/) + end +end + +def admin_ui_file?(file) + return false if file.match?(SETTINGS_SEARCH_UI_REGEX) + return true if file.match?(CORE_ADMIN_UI_REGEX) + + module_dir = file[MODULE_UI_REGEX, 1] + !module_dir.nil? && registers_with_settings_search?(module_dir) +end + +changed_files = git.modified_files + git.added_files +admin_ui_files = changed_files.select { |file| admin_ui_file?(file) } +registries_changed = changed_files.any? do |file| + SETTINGS_SEARCH_REGISTRIES.include?(file) || file.match?(MODULE_ENGINE_REGEX) +end + +if admin_ui_files.any? && !registries_changed + message_lines = [ + "**Settings search:** admin pages were changed without changing the settings search registries:", + "" + ] + + admin_ui_files.each do |file| + message_lines << "- #{file}" + end + + message_lines << <<~MESSAGE + + If settings, form fields or actions creating an entity were added, renamed or moved, please update + `config/initializers/settings_pages.rb`, `config/initializers/settings_entity_kinds.rb` or the + registrations in the module's engine, following the [checklist](#{SETTINGS_SEARCH_DOCS_URL}). + Specs catch unregistered global settings, but the fields of entities such as types or providers are + only checked against what is registered. + MESSAGE + + warn message_lines.join("\n") +end diff --git a/Dangerfile b/Dangerfile index 4b8837e550d5..5a3d3492655d 100644 --- a/Dangerfile +++ b/Dangerfile @@ -33,3 +33,4 @@ danger.import_dangerfile(path: ".github/dangerfiles/project_id_contract/Dangerfi danger.import_dangerfile(path: ".github/dangerfiles/op_primer_changes/Dangerfile") danger.import_dangerfile(path: ".github/dangerfiles/migration_index_names/Dangerfile") danger.import_dangerfile(path: ".github/dangerfiles/html_safe/Dangerfile") +danger.import_dangerfile(path: ".github/dangerfiles/settings_search/Dangerfile") diff --git a/config/constants/settings/pages.rb b/config/constants/settings/pages.rb index 0f23ae803598..e7b36dd77fc3 100644 --- a/config/constants/settings/pages.rb +++ b/config/constants/settings/pages.rb @@ -54,6 +54,11 @@ module Settings # `form_field: false`. The element representing them needs a # `data-setting-name` attribute for the settings search to highlight it. # + # Settings that can be changed but are not shown in the administration, + # e.g. as they are configured through the environment, are declared with + # `not_in_administration`, so that every setting is either found by the + # settings search or deliberately left out of it. + # # @example # Settings::Pages.draw do # page :general, menu_item: :settings_general do @@ -238,6 +243,14 @@ def all registry.values end + def not_in_administration(*names) + settings_not_in_administration.merge(names.map(&:to_sym)) + end + + def settings_not_in_administration + @settings_not_in_administration ||= Set.new + end + def admin_menu Redmine::MenuManager.items(:admin_menu) end diff --git a/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb index 546f738249c1..a377a94d88ff 100644 --- a/config/initializers/settings_pages.rb +++ b/config/initializers/settings_pages.rb @@ -39,6 +39,41 @@ # # See also: [settings entity kinds initializer](settings_entity_kinds.rb) Settings::Pages.draw do + not_in_administration :additional_host_names, :antivirus_scan_available, :apiv3_enable_basic_auth, + :appsignal_frontend_key, :attachment_default_charset, + :attachments_cleanup_period_hours, :attachments_grace_period, :authentication, + :autologin_cookie_name, :autologin_cookie_path, :avatar_link_expiration_seconds, + :backup_attachment_size_max_sum_mb, :backup_daily_limit, :backup_enabled, + :backup_include_attachments, :backup_initial_waiting_period, :blocked_email_domains, + :boards_demo_data_available, :commit_logs_encoding, :consent_time, + :cost_reporting_cache_filter_classes, :days_per_month, :default_comment_sort_order, + :demo_projects_available, :demo_view_of_type_gantt_seeded, + :demo_view_of_type_team_planner_seeded, :demo_view_of_type_work_packages_table_seeded, + :development_highlight_enabled, :disable_keyboard_shortcuts, :disable_password_choice, + :disable_password_login, :drop_old_sessions_on_login, :drop_old_sessions_on_logout, + :ee_hide_banners, :email_login, :fog, :fog_direct_upload_expires_in, + :fog_download_url_expires_in, :force_formatting_help_link, :force_help_link, + :forced_single_page_size, :good_job_engine_basic_auth, :gravatar_fallback_image, + :hashed_token_pepper, :health_checks_authentication_password, + :health_checks_backlog_threshold, :health_checks_jobs_never_ran_minutes_ago, :home_url, + :ical_feed_keep_closed_meetings_days, :impressum_link, :installation_uuid, + :internal_password_confirmation, :ldap_departments_disable_sync_job, + :ldap_force_no_page, :ldap_groups_disable_sync_job, :ldap_users_disable_sync_job, + :ldap_users_sync_status, :lookbook_enabled, :mail_handler_api_key, + :mail_suffix_separators, :notifications_hidden, :notifications_polling_interval, + :onboarding_enabled, :opentelemetry_enabled, :password_login_bypass_logins, + :plugin_costs, :plugin_openproject_auth_saml, :plugin_openproject_backlogs, + :plugin_openproject_bim, :plugin_openproject_boards, :plugin_openproject_calendar, + :plugin_openproject_gantt, :plugin_openproject_ldap_departments, + :plugin_openproject_ldap_groups, :plugin_openproject_openid_connect, + :plugin_openproject_resource_management, :plugin_openproject_storages, + :plugin_openproject_team_planner, :rate_limiting, :recaptcha_via_hcaptcha, + :report_incoming_email_errors, :session_cookie_name, :show_community_links, + :show_product_version, :show_setting_mismatch_warning, :show_storage_information, + :show_warning_bars, :smtp_timeout, :software_name, :software_url, :user_default_theme, + :work_packages_activities_tab_polling_interval_in_ms, + :work_packages_bulk_request_limit, :youtube_channel + page :general, menu_item: :settings_general do setting :app_title setting :organization_name diff --git a/docs/development/concepts/settings-search/README.md b/docs/development/concepts/settings-search/README.md index e36f1f60ce0f..c298ba653ab1 100644 --- a/docs/development/concepts/settings-search/README.md +++ b/docs/development/concepts/settings-search/README.md @@ -251,8 +251,8 @@ other, so they drift apart whenever a view is changed without updating the regis 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: +Specs render the admin pages and compare them with the registries, and check that every setting is +classified. They are the safety net for every change to an admin page and must pass: ### `spec/requests/admin/settings/registered_pages_spec.rb` @@ -269,6 +269,19 @@ conversion or an enabled collaboration server), so that settings shown condition 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/admin_pages_spec.rb` + +Renders the page of every admin menu item, whether registered or not, and fails for every +`settings[]` field of a defined setting and every `data-setting-name` marker that is not registered +for that menu item. This catches settings on new admin pages, which the spec above does not render. + +### `spec/constants/settings/pages_spec.rb` + +Checks that every setting that can be changed is either registered on a page or declared with +`not_in_administration` in `config/initializers/settings_pages.rb`, e.g. because it is configured through +the environment. A new setting in `config/constants/settings/definition.rb` or a plugin therefore fails +this spec until it is classified. Feature flags are left out automatically. + ### `spec/requests/admin/settings/entity_attributes_spec.rb` Creates entities of every kind and renders each page an attribute leads to, checking that every @@ -279,6 +292,10 @@ registered `field:` is present there. Add an entry for every new kind with attri - 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. +- New fields of entity forms, as the attributes of a kind are a selection of them. + +For the latter, Danger warns on pull requests changing admin views, forms or components without changing +a registry (`.github/dangerfiles/settings_search`). ### Checklist when changing an admin page @@ -288,7 +305,8 @@ registered `field:` is present there. Add an entry for every new kind with attri 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`. +5. Adding a setting not shown in the administration: declare it with `not_in_administration`. +6. Run the specs above and `spec/models/settings/search_tree_spec.rb`. ### Future work diff --git a/spec/constants/settings/pages_spec.rb b/spec/constants/settings/pages_spec.rb index 7cdebf3eae12..90637d96e877 100644 --- a/spec/constants/settings/pages_spec.rb +++ b/spec/constants/settings/pages_spec.rb @@ -169,4 +169,23 @@ end end end + + describe "settings not in the administration" do + let(:registered) { described_class.all.flat_map(&:entries).map(&:name) } + let(:not_in_administration) { described_class.settings_not_in_administration.to_a } + + it "together with the registered settings cover every setting that can be changed" do + changeable = Settings::Definition.all.values.select(&:writable?).map { it.name.to_sym } + unclassified = changeable - registered - not_in_administration - OpenProject::FeatureDecisions.setting_names + + expect(unclassified).to be_empty, + "Register #{unclassified.join(', ')} in config/initializers/settings_pages.rb, either " \ + "on the page showing it or with `not_in_administration`." + end + + it "are defined and not registered on a page", :aggregate_failures do + expect(not_in_administration.reject { Settings::Definition.exists?(it) }).to be_empty + expect(not_in_administration & registered).to be_empty + end + end end diff --git a/spec/requests/admin/settings/admin_pages_spec.rb b/spec/requests/admin/settings/admin_pages_spec.rb new file mode 100644 index 000000000000..40dc40698450 --- /dev/null +++ b/spec/requests/admin/settings/admin_pages_spec.rb @@ -0,0 +1,86 @@ +# 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 on admin pages", + :settings_reset, + type: :rails_request, + with_ee: %i[sso_auth_providers mcp_server scim_api ldap_groups customize_life_cycle + custom_actions placeholder_users xwiki_integration define_custom_style], + with_flag: { llm_connection: true, ai_text_transform_actions: true, wiki_enhancements: true } do + shared_let(:admin) { create(:admin) } + shared_let(:internal_wiki_provider) { create(:internal_wiki_provider) } + + before { login_as(admin) } + + let(:view_context) { ApplicationController.new.tap { it.request = ActionDispatch::TestRequest.create }.view_context } + let(:menu_item_names) { Settings::Pages.admin_menu.to_set { it.name.to_s } } + + def menu_item_nodes(nodes) + nodes.flat_map do |node| + own = menu_item_names.include?(node.key) ? [node] : [] + [*own, *menu_item_nodes(node.children)] + end + end + + # Menu items with children link to the page of one of them. + def registered_names(menu_item) + menu_items = Settings::Pages.admin_menu.find { it.name.to_s == menu_item }.map(&:name) + entries = Settings::Pages.all.select { menu_items.include?(it.menu_item) }.flat_map(&:entries) + entries.flat_map { [it.highlight, it.field || "settings[#{it.name}]"] }.to_set + end + + def rendered_names + fields = response.parsed_body.css("[name^='settings[']") + .map { it["name"][/\Asettings\[([^\]]+)\]/, 1] } + .select { Settings::Definition.exists?(it) } + .map { "settings[#{it}]" } + markers = response.parsed_body.css("[data-setting-name]").map { it["data-setting-name"] } + (fields + markers).uniq + end + + it "registers every setting rendered on the page of an admin menu item", :aggregate_failures do + nodes = menu_item_nodes(User.execute_as(admin) { Settings::SearchTree.new(view_context).nodes }) + + expect(nodes).not_to be_empty + + nodes.each do |node| + get node.href + + next if response.redirect? + + expect(response).to have_http_status(:ok), "for #{node.href}" + + unregistered = rendered_names - registered_names(node.key).to_a + expect(unregistered).to be_empty, "unregistered settings on #{node.href}: #{unregistered.join(', ')}" + end + end +end From debedbb7d88647168cc8e766ca27eddc613c8c51 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 26 Oct 2026 12:46:34 +0000 Subject: [PATCH 19/19] Find the custom PDF export fonts in the settings search The PDF export font tab of the design page was merged into the PDF export styles tab, so searching for "font" no longer found the custom fonts and font colors. Link their sections on that tab. --- config/initializers/settings_pages.rb | 3 +++ spec/models/settings/search_tree_spec.rb | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb index a377a94d88ff..cc211c844cff 100644 --- a/config/initializers/settings_pages.rb +++ b/config/initializers/settings_pages.rb @@ -441,6 +441,9 @@ %w[interface branding default_colors pdf_export_styles].each do |tab| link tab, label: :"admin.custom_styles.tab_#{tab}", url: -> { custom_style_path(tab:) } end + %i[label_custom_export_font label_custom_export_font_colors].each do |label| + link label, label:, url: -> { custom_style_path(tab: :pdf_export_styles) } + end link :new_color, label: :"colors.index.label_new_color", url: -> { new_color_path } end diff --git a/spec/models/settings/search_tree_spec.rb b/spec/models/settings/search_tree_spec.rb index 0d39264b8e9d..caa6fa35fc03 100644 --- a/spec/models/settings/search_tree_spec.rb +++ b/spec/models/settings/search_tree_spec.rb @@ -132,6 +132,15 @@ def find_node(nodes, *labels) end end + context "with a query matching a section of a design tab" do + let(:query) { "font" } + + it "leads to the tab" do + expect(find_node(tree.nodes, "Design", "Custom Font").href) + .to eq url_helpers.custom_style_path(tab: :pdf_export_styles) + 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