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/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/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/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..1e601a5cdac2
--- /dev/null
+++ b/app/components/admin/settings_search/tree_component.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module 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)
+ 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)
+ { 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..ba9ced779643
--- /dev/null
+++ b/app/components/admin/settings_search_component.sass
@@ -0,0 +1,70 @@
+//-- 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
+
+ &--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
+
+@keyframes op-setting-highlight-fade
+ to
+ opacity: 0
+
+.op-setting--highlighted
+ 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
diff --git a/app/components/admin/text_transform_actions/setting_toggle_component.html.erb b/app/components/admin/text_transform_actions/setting_toggle_component.html.erb
index 98fcd23cb13a..b45f87ba6e53 100644
--- a/app/components/admin/text_transform_actions/setting_toggle_component.html.erb
+++ b/app/components/admin/text_transform_actions/setting_toggle_component.html.erb
@@ -37,7 +37,7 @@ See COPYRIGHT and LICENSE files for more details.
status_label_position: :start,
checked: enabled?,
enabled: writable?,
- data: { turbo: true },
+ data: { turbo: true, setting_name: SETTING_NAME },
test_selector: "toggle-ai-text-transform-actions-enabled",
mb: 3
)
diff --git a/app/components/work_packages/admin/settings/target_versions_section_component.html.erb b/app/components/work_packages/admin/settings/target_versions_section_component.html.erb
index 42687ebd4d9d..8a6a8010d495 100644
--- a/app/components/work_packages/admin/settings/target_versions_section_component.html.erb
+++ b/app/components/work_packages/admin/settings/target_versions_section_component.html.erb
@@ -33,7 +33,13 @@ See COPYRIGHT and LICENSE files for more details.
<% box.with_row(icon: :info) { scoped_t(:"planned_changes.single_value") } %>
<% end %>
- <%= render(WorkPackages::Admin::Settings::NoticeBoxComponent.new(heading: scoped_t(:"action_required.heading"), mt: 3)) do |box| %>
+ <%= render(
+ WorkPackages::Admin::Settings::NoticeBoxComponent.new(
+ heading: scoped_t(:"action_required.heading"),
+ mt: 3,
+ data: { setting_name: "work_package_multiple_versions" }
+ )
+ ) do |box| %>
<% if multiple_versions_writable? %>
<% box.with_row(icon: :tools) { scoped_t(:"action_required.manual_conversion_hint") } %>
<% else %>
diff --git a/app/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/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/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.
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/app/models/settings/search_tree.rb b/app/models/settings/search_tree.rb
new file mode 100644
index 000000000000..8643d7d0530e
--- /dev/null
+++ b/app/models/settings/search_tree.rb
@@ -0,0 +1,332 @@
+# 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
+ # 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".
+ #
+ # 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
+
+ # 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
+
+ # 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
+
+ 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)
+ text.to_s.unicode_normalize(:nfd).downcase.gsub(/\p{M}/, "").split(/[^\p{L}\p{N}]+/).compact_blank
+ end
+
+ 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.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
+ @entity_kinds_by_menu_item = entity_kinds.group_by(&:menu_item)
+ @menu = menu
+ @hidden_menu_items = hidden_menu_items.map(&:to_s)
+ end
+
+ attr_reader :query_words
+
+ def nodes
+ @nodes ||= filter(assemble(@menu), [])
+ end
+
+ def excerpt(node)
+ nodes
+ excerpts[node.key]
+ end
+
+ private
+
+ attr_reader :view_context
+
+ def assemble(menu_node)
+ menu_node.children.filter_map do |item|
+ next unless visible_menu_item?(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)]
+
+ if page.label && leaves.any?
+ [Node.new(key: page.key.to_s,
+ label: view_context.t(page.label),
+ description: nil,
+ href: page_url(page),
+ 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.highlight}",
+ label: entry.label(view_context),
+ description: plain_text(entry.caption(view_context)),
+ 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 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?
+
+ nodes.filter_map do |node|
+ path_words = [*ancestor_words, *self.class.words(node.label)]
+ 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)
+ 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.to_s.gsub(%r{
|
}i, " ")).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
+ end
+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/constants/settings/entity_kinds.rb b/config/constants/settings/entity_kinds.rb
new file mode 100644
index 000000000000..eb4522168367
--- /dev/null
+++ b/config/constants/settings/entity_kinds.rb
@@ -0,0 +1,183 @@
+# 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.
+ # 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
+ # 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, **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
+
+ 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
+
+ # 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]
+ 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
+
+ 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/constants/settings/pages.rb b/config/constants/settings/pages.rb
new file mode 100644
index 000000000000..e7b36dd77fc3
--- /dev/null
+++ b/config/constants/settings/pages.rb
@@ -0,0 +1,265 @@
+# 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.
+ #
+ # 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
+ # `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
+ # setting :app_title
+ #
+ # section :welcome, if: -> { Setting.welcome_on_homescreen? } do
+ # 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
+ # setting :my_plugin_setting
+ # end
+ module Pages
+ class Entry
+ attr_reader :name, :field, :condition
+
+ 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
+
+ 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 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
+
+ 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, :links
+
+ 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 = []
+ @links = []
+ 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 link(key, **)
+ @links << Link.new(key, **)
+ end
+
+ def visible_links
+ links.select(&:visible?)
+ 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 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
+
+ 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..c5817d88204f
--- /dev/null
+++ b/config/initializers/settings_entity_kinds.rb
@@ -0,0 +1,245 @@
+# 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.
+#++
+
+##
+# 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]"
+ 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
+
+ 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/config/initializers/settings_pages.rb b/config/initializers/settings_pages.rb
new file mode 100644
index 000000000000..cc211c844cff
--- /dev/null
+++ b/config/initializers/settings_pages.rb
@@ -0,0 +1,465 @@
+# 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.
+#++
+
+##
+# 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
+ 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
+ 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
+ setting :work_package_list_default_columns, form_field: false, label: :setting_column_options
+ end
+
+ page :work_packages_identifier, menu_item: :work_packages_identifier do
+ 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
+ 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 :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"
+ 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
+
+ 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
+ %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
+
+ 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/config/locales/en.yml b/config/locales/en.yml
index 0fbb57997d02..aa26b82bf1b1 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."
@@ -6321,6 +6325,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/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/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..c298ba653ab1
--- /dev/null
+++ b/docs/development/concepts/settings-search/README.md
@@ -0,0 +1,317 @@
+---
+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, 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. 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
+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:
+
+```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
+
+```
+
+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.
+
+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`
+
+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/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
+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.
+- 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
+
+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. 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
+
+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.
+
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/frontend/src/stimulus/controllers/highlight-setting.controller.ts b/frontend/src/stimulus/controllers/highlight-setting.controller.ts
new file mode 100644
index 000000000000..b56f59ac4c4b
--- /dev/null
+++ b/frontend/src/stimulus/controllers/highlight-setting.controller.ts
@@ -0,0 +1,90 @@
+//-- 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';
+
+// 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 match = HIGHLIGHT_PATTERN.exec(url.searchParams.get('highlight') ?? '');
+ if (!match) return;
+
+ const containers = this.findContainers(match[1], match[2]);
+ if (containers.length === 0) return;
+
+ 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 findContainers(name:string, value:string|undefined):HTMLElement[] {
+ const marked = document.querySelector(`[data-setting-name="${name}"]`);
+ 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}]["]`];
+
+ 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/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/lib/open_project/plugins/acts_as_op_engine.rb b/lib/open_project/plugins/acts_as_op_engine.rb
index 8ba37eb38a4e..a12f62edef78 100644
--- a/lib/open_project/plugins/acts_as_op_engine.rb
+++ b/lib/open_project/plugins/acts_as_op_engine.rb
@@ -32,6 +32,8 @@
require_relative "../../../config/constants/open_project/activity"
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/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb
index ac2e88b744be..5c3655ef57da 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,54 @@ 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
+
+ ::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
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 c81b004c4eee..069db3e960db 100644
--- a/modules/costs/lib/costs/engine.rb
+++ b/modules/costs/lib/costs/engine.rb
@@ -193,6 +193,59 @@ 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
+
+ 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
+
+ ::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
replace_principal_references "CostEntry" => %i[logged_by_id user_id]
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/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.
diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb
index fe807e947ffe..2e124a7b549c 100644
--- a/modules/documents/lib/open_project/documents/engine.rb
+++ b/modules/documents/lib/open_project/documents/engine.rb
@@ -105,6 +105,41 @@ 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
+
+ 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
+
+ ::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
patches %i[Project]
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..ecbf4fc702cc 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,32 @@ 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
+
+ ::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",
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..a378d199ffc9 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,36 @@ 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
+
+ ::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",
author_url: "https://github.com/opf/openproject-ldap_groups",
bundled: true,
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/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb
index 41bf4a20878a..096d70566e2f 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,63 @@ 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
+
+ ::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
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..b5a18db87d85 100644
--- a/modules/storages/lib/open_project/storages/engine.rb
+++ b/modules/storages/lib/open_project/storages/engine.rb
@@ -48,6 +48,31 @@ 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
+
+ ::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]
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..7412f27f5e38 100644
--- a/modules/webhooks/lib/open_project/webhooks/engine.rb
+++ b/modules/webhooks/lib/open_project/webhooks/engine.rb
@@ -33,6 +33,26 @@ 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
+
+ ::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",
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..5ba43a4a1be0 100644
--- a/modules/wikis/lib/open_project/wikis/engine.rb
+++ b/modules/wikis/lib/open_project/wikis/engine.rb
@@ -37,6 +37,38 @@ 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
+
+ ::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
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "XWiki"
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..53b6f4962fb6
--- /dev/null
+++ b/spec/components/admin/settings_search/tree_component_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+
+require "rails_helper"
+
+RSpec.describe 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(:excerpt) { nil }
+ let(:tree) { instance_double(Settings::SearchTree, nodes:, query_words:, excerpt:) }
+
+ 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 "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) { [] }
+
+ 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/constants/settings/entity_kinds_spec.rb b/spec/constants/settings/entity_kinds_spec.rb
new file mode 100644
index 000000000000..d0ca5ed2d289
--- /dev/null
+++ b/spec/constants/settings/entity_kinds_spec.rb
@@ -0,0 +1,99 @@
+# 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 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
+
+ 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
+
+ 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
+ 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/constants/settings/pages_spec.rb b/spec/constants/settings/pages_spec.rb
new file mode 100644
index 000000000000..90637d96e877
--- /dev/null
+++ b/spec/constants/settings/pages_spec.rb
@@ -0,0 +1,191 @@
+# 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 "#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)
+ 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 "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 }
+
+ 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
+
+ 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/features/admin/settings_search_spec.rb b/spec/features/admin/settings_search_spec.rb
new file mode 100644
index 000000000000..7aebf95000e5
--- /dev/null
+++ b/spec/features/admin/settings_search_spec.rb
@@ -0,0 +1,174 @@
+# 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 "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"
+
+ 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 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
+
+ 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
new file mode 100644
index 000000000000..caa6fa35fc03
--- /dev/null
+++ b/spec/models/settings/search_tree_spec.rb
@@ -0,0 +1,355 @@
+# 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) { [] }
+ let(:query) { nil }
+
+ 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)
+ 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
+
+ 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 }
+
+ 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 "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", "Versions and categories").children).to be_empty
+ 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
+
+ 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
+
+ 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
+
+ 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")
+
+ expect(node.href).to eq "/admin/settings/versions_and_categories?highlight=work_package_multiple_versions"
+ 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
+
+ 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
+ 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
+
+ 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
+ let(:query) { "xyzzy" }
+
+ it "is empty" do
+ expect(tree.nodes).to be_empty
+ 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
+
+ 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
+ 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"] }
+
+ it "omits its settings" do
+ 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
+ current_user { create(:user) }
+
+ it "is empty" do
+ expect(tree.nodes).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
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..714e68150b28
--- /dev/null
+++ b/spec/requests/admin/settings/entity_attributes_spec.rb
@@ -0,0 +1,105 @@
+# 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 sso_auth_providers scim_api
+ customize_life_cycle ldap_groups xwiki_integration mcp_server] 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 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 { 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}"
+
+ 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")] },
+ 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)
+
+ instance_exec(&entities).each { expect_fields_rendered(kind, it) }
+ end
+ end
+end
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..713eff1842c0
--- /dev/null
+++ b/spec/requests/admin/settings/registered_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 "Registered settings pages",
+ :settings_reset,
+ type: :rails_request,
+ with_ee: %i[sso_auth_providers mcp_server],
+ with_flag: { llm_connection: true, ai_text_transform_actions: true } 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
+
+ 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"] }.uniq
+ end
+
+ shared_examples "registered settings pages" do
+ 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(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
+
+ 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
diff --git a/spec/requests/admin/settings_search_spec.rb b/spec/requests/admin/settings_search_spec.rb
new file mode 100644
index 000000000000..542c801b1cc1
--- /dev/null
+++ b/spec/requests/admin/settings_search_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 "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
+
+ 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
+ let(:user) { create(:user) }
+
+ it "is forbidden" do
+ get tree_admin_settings_search_path
+
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+end
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