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 + + + Disable + + +``` + +In Primer forms, pass the marker as data of the input or group representing the setting: + +```ruby +sf.fieldset_group(title: I18n.t(:setting_project_gantt_query), + data: { setting_name: "project_gantt_query" }) do |fg| + # … +end +``` + +When the entry has a `field:`, the marker uses that field instead of the setting's name. + +## Actions: links to "new entity" pages + +Pages register their actions as links. A link opens its URL directly without highlighting: + +```ruby +page :oauth_applications, menu_item: :oauth_applications do + link :new, label: :"oauth.application.new", url: -> { new_oauth_application_path } +end +``` + +- `url:` is a lambda evaluated in the view context, so route helpers and parameters can be used, e.g. + one link per provider type: `new_openid_connect_provider_path(oidc_provider: type)`. +- `if:` hides the link, e.g. when the enterprise feature required by the button is not available, so + that the search does not lead to a page refusing the action. +- `label:` may be a lambda, e.g. to show "Reset backup token" instead of "Create backup token". + +Actions opening a dialog through a Turbo Stream request (e.g. "Add Enterprise token") cannot be opened +by a link. Link to the page showing the button instead. + +## Entities and their attributes + +Entities such as work package types, roles or OpenID providers are registered as entity kinds. A kind +lists its attributes below its menu item once, leading to the attribute on the first entity it applies +to. Entities themselves are only listed when their name matches a word of the query, at most +`Settings::EntityKinds::MATCH_LIMIT` per kind, so that instances with thousands of entities keep a +usable tree. "work package type bug color" therefore lists the color of the type "Bug". + +```ruby +Settings::EntityKinds.draw do + kind :types, menu_item: :types, scope: -> { Type.order(:position) }, + url: ->(type) { edit_type_details_path(type) } do + attribute :color, label: -> { Color.model_name.human }, field: "type[color_id]" + attribute :form_configuration, + label: :"types.edit.form_configuration.tab", + url: ->(type) { edit_type_form_configuration_path(type) } + end +end +``` + +- `scope:` lists the entities, `name:` names the column matched against the query (default `:name`) and + `url:` the page of an entity. +- `field:` names the form field to highlight on the entity's page, in the formats listed under + [Highlighting](#highlighting-a-setting-on-its-page). Without a field, the attribute just opens the page. +- `url:` on an attribute leads to another page or tab of the entity, e.g. the form configuration tab of + a type or a section of an OpenID provider (`edit_openid_connect_provider_path(provider, edit_state: :client_details)`). +- `if:` on an attribute receives the entity and limits the entities the attribute applies to, e.g. a + custom field's minimum and maximum length only for formats having a length. Attributes of edit pages showing + sections in display mode (e.g. storages) are registered without a field. +- `group:` nests attributes below a common node, e.g. the permissions of a role. +- `attributes_from` builds attributes lazily, e.g. one per permission. +- `if:` on the kind hides the kind altogether, e.g. without the enterprise feature required to edit its + entities. + +Single records shown like settings, such as the switch enabling project wikis +(`Wikis::InternalProvider`) or the announcement, are registered as kinds with a single entity. + +## Keeping the registries in sync + +The views and the registries must describe the same settings and fields. Nothing derives one from the +other, so they drift apart whenever a view is changed without updating the registry, or the other way +round. The search then lists settings that do not exist on the page, links to the wrong page, highlights +nothing or misses settings entirely. + +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