diff --git a/app/components/custom_fields/details_component.rb b/app/components/custom_fields/details_component.rb index 0eb53f09011e..320c4a4a1c3c 100644 --- a/app/components/custom_fields/details_component.rb +++ b/app/components/custom_fields/details_component.rb @@ -45,9 +45,17 @@ class DetailsComponent < ApplicationComponent def form_url if model.new_record? - model.type == "ProjectCustomField" ? admin_settings_project_custom_fields_path : custom_fields_path + case model.type + when "ProjectCustomField" then admin_settings_project_custom_fields_path + when "UserCustomField" then admin_settings_user_custom_fields_path + else custom_fields_path + end else - model.type == "ProjectCustomField" ? admin_settings_project_custom_field_path(model) : custom_field_path(model) + case model.type + when "ProjectCustomField" then admin_settings_project_custom_field_path(model) + when "UserCustomField" then admin_settings_user_custom_field_path(model) + else custom_field_path(model) + end end end diff --git a/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb b/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb index 075ce7dbd376..48fc90689f3e 100644 --- a/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb @@ -42,8 +42,9 @@ def initialize(project:, project_custom_field_section:) @project = project @project_custom_field_section = project_custom_field_section - enabled_custom_field_ids = project.enabled_custom_field_ids - @project_custom_fields = project_custom_field_section.custom_fields.where(id: enabled_custom_field_ids) + enabled_ids = project.enabled_custom_field_ids.to_set + @project_custom_fields = project_custom_field_section.custom_fields_in_order + .select { |cf| enabled_ids.include?(cf.id) } end private diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb index 1a5428694eb8..a5de0894e196 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -41,7 +41,7 @@ def initialize(project:, project_custom_field_section:) @project = project @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields + @project_custom_fields = project_custom_field_section.custom_fields_in_order end private diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index a0167b860435..c5cb8f75ce93 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -35,11 +35,12 @@ class CustomFieldRowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project_custom_field:, first_and_last:) - super + def initialize(project_custom_field:, first:, last:) + super() @project_custom_field = project_custom_field - @first_and_last = first_and_last + @first = first + @last = last end private @@ -101,21 +102,11 @@ def delete_action_item(menu) end def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @project_custom_field - else - @project_custom_field.first? - end + @first end def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @project_custom_field - else - @project_custom_field.last? - end + @last end end end diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb index 4b3e3f0e6849..f57827bca4e7 100644 --- a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb @@ -51,7 +51,12 @@ def form_config { model: @project_custom_field_section, method: @project_custom_field_section.persisted? ? :put : :post, - url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path + url: if @project_custom_field_section.persisted? + admin_settings_project_custom_field_section_path(@project_custom_field_section) + else + admin_settings_project_custom_field_sections_path + end, + data: { turbo_stream: true } } end end diff --git a/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb index 211c3cdf346a..6a81fb54d622 100644 --- a/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb +++ b/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb @@ -2,7 +2,8 @@ Primer::Alpha::Dialog.new( title: t("settings.project_attributes.label_new_section"), size: :medium_portrait, - id: MODAL_ID + id: MODAL_ID, + data: { "keep-open-on-submit": true } ) ) do %> <%= render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new) %> diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index 6ce3bf4f9699..dad90516a72b 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -52,11 +52,28 @@ menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.project_attributes.label_section_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) - if @project_custom_fields.empty? + if @ordered_cfs.empty? delete_action_item(menu) else disabled_delete_action_item(menu) end + + menu.with_divider + + menu.with_sub_menu_item( + label: t("settings.project_attributes.label_new_attribute"), + content_arguments: { + aria: { label: t("settings.project_attributes.label_add_attribute") }, + data: { test_selector: "new-project-custom-field-in-section-submenu" } + } + ) do |sub_menu| + sub_menu.with_leading_visual_icon(icon: :plus) + OpenProject::CustomFieldFormat.available_for_class_name("Project") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(sub_menu, format) + end + end end end @@ -64,7 +81,8 @@ render( Primer::Alpha::Dialog.new( id: "project-custom-field-section-dialog#{@project_custom_field_section.id}", title: t("settings.project_attributes.label_new_section"), - size: :medium_portrait + size: :medium_portrait, + data: { "keep-open-on-submit": true } ) ) do |_dialog| render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) @@ -73,7 +91,7 @@ end end end - if @project_custom_fields.empty? + if @ordered_cfs.empty? component.with_row(data: { "empty-list-item": true }) do flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| empty_list_container.with_column(ml: 4, mr: 2) do @@ -93,23 +111,21 @@ OpenProject::CustomFieldFormat.available_for_class_name("Project") .sort_by(&:name) - .map do |format| - action_menu_item_for_custom_field_format(menu, format) + .each do |format| + action_menu_item_for_custom_field_format(menu, format) end end end end end else - first_and_last = [@project_custom_fields.first, @project_custom_fields.last] - @project_custom_fields.each do |project_custom_field| - component.with_row( - data: draggable_item_config(project_custom_field) - ) do + @ordered_cfs.each_with_index do |cf, index| + component.with_row(data: draggable_item_config(cf)) do render( custom_field_row_component_class.new( - project_custom_field:, - first_and_last: + project_custom_field: cf, + first: index == 0, + last: index == @ordered_cfs.size - 1 ) ) end diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index d6c5203de2f9..be36c7fecfc7 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -39,7 +39,7 @@ def initialize(project_custom_field_section:, first_and_last: []) super @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields + @ordered_cfs = project_custom_field_section.custom_fields_in_order @first_and_last = first_and_last end @@ -57,9 +57,9 @@ def wrapper_uniq_by def drag_and_drop_target_config { generic_drag_and_drop_target: "container", - "target-container-accessor": ".Box > ul", # the accessor of the container that contains the drag and drop items - "target-id": @project_custom_field_section.id, # the id of the target - "target-allowed-drag-type": "custom-field" # the type of dragged items which are allowed to be dropped in this target + "target-container-accessor": ".Box > ul", + "target-id": @project_custom_field_section.id, + "target-allowed-drag-type": "custom-field" } end @@ -132,21 +132,23 @@ def delete_action_item(menu) end def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @project_custom_field_section - else - @project_custom_field_section.first? - end + return @first unless @first.nil? + + @first = if @first_and_last.first + @first_and_last.first == @project_custom_field_section + else + @project_custom_field_section.first? + end end def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @project_custom_field_section - else - @project_custom_field_section.last? - end + return @last unless @last.nil? + + @last = if @first_and_last.last + @first_and_last.last == @project_custom_field_section + else + @project_custom_field_section.last? + end end def action_menu_item_for_custom_field_format(menu, format) diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb new file mode 100644 index 000000000000..2f5ae324e5ee --- /dev/null +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb @@ -0,0 +1,32 @@ +<%= + component_wrapper( + class: "op-user-built-in-attribute-container", + data: { test_selector: "user-built-in-attribute-container-#{@attribute_key}" } + ) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_weight: :bold)) { attribute_label } + end + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :secondary, size: :medium)) do + t(".label_built_in") + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "built-in-attribute-action-menu" })) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + "aria-label": t(".label_built_in_attribute_actions"), + scheme: :invisible + ) + move_actions(menu) + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb new file mode 100644 index 000000000000..3b442fc27b82 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb @@ -0,0 +1,79 @@ +# 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 + module UserCustomFieldSections + class BuiltInAttributeRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(section:, attribute_key:, first:, last:) + super() + @section = section + @attribute_key = attribute_key + @first = first + @last = last + end + + def attribute_label + User.human_attribute_name(@attribute_key) + end + + private + + def move_actions(menu) + unless @first + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless @last + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item( + label: label_text, + href: move_admin_settings_user_custom_field_section_built_in_attribute_path( + @section, @attribute_key, move_to: + ), + form_arguments: { + method: :put, + data: { "turbo-stream": true, test_selector: "built-in-attribute-move-#{move_to}" } + } + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb new file mode 100644 index 000000000000..d7382d0fbea9 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb @@ -0,0 +1,43 @@ +<%= + component_wrapper(class: "op-user-custom-field-container", data: { test_selector: "user-custom-field-container-#{@user_custom_field.id}" }) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column(mr: 2) do + render( + Primer::Beta::Link.new( + href: edit_admin_settings_user_custom_field_path(@user_custom_field), + underline: false, + font_weight: :bold, + data: { turbo: "false" } + ) + ) do + @user_custom_field.name + end + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + helpers.label_for_custom_field_format(@user_custom_field.field_format) + end + end + if @user_custom_field.required? + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + UserCustomField.human_attribute_name(:required) + end + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(".label_user_custom_field_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + delete_action_item(menu) + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.rb b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..854ebc0b05b9 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,93 @@ +# 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 + module UserCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field:, first:, last:) + super() + @user_custom_field = user_custom_field + @first = first + @last = last + end + + private + + def edit_action_item(menu) + menu.with_item(label: t("label_edit"), + href: edit_admin_settings_user_custom_field_path(@user_custom_field), + data: { turbo: "false", test_selector: "user-custom-field-edit" }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_actions(menu) + unless @first + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless @last + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_user_custom_field_path(@user_custom_field, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, test_selector: "user-custom-field-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_user_custom_field_path(@user_custom_field), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t(:text_are_you_sure), + turbo_stream: true, + test_selector: "user-custom-field-delete" + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb new file mode 100644 index 000000000000..3001cdd1b4e5 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb @@ -0,0 +1,23 @@ +<%= + component_wrapper do + primer_form_with(**form_config) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new) do + render(UserCustomFieldSections::NameForm.new(f)) + end + + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |modal_footer| + modal_footer.with_component(Primer::Beta::Button.new(data: { "close-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}" })) do + t("button_cancel") + end + + modal_footer.with_component(Primer::Beta::Button.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb new file mode 100644 index 000000000000..78e7f62792c0 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb @@ -0,0 +1,64 @@ +# 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 + module UserCustomFieldSections + class DialogBodyFormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_section: UserCustomFieldSection.new) + super + + @user_custom_field_section = user_custom_field_section + end + + private + + def wrapper_uniq_by + @user_custom_field_section.id + end + + def form_config + { + model: @user_custom_field_section, + method: @user_custom_field_section.persisted? ? :put : :post, + url: if @user_custom_field_section.persisted? + admin_settings_user_custom_field_section_path(@user_custom_field_section) + else + admin_settings_user_custom_field_sections_path + end, + data: { turbo_stream: true } + } + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/index_component.html.erb b/app/components/settings/user_custom_field_sections/index_component.html.erb new file mode 100644 index 000000000000..a496772cc3ee --- /dev/null +++ b/app/components/settings/user_custom_field_sections/index_component.html.erb @@ -0,0 +1,15 @@ +<%= + component_wrapper(data: wrapper_data_attributes) do + if @user_custom_field_sections.any? + flex_layout(classes: "dragula-container", data: { "allowed-drop-type": "section" }.merge(drop_target_config)) do |flex| + @user_custom_field_sections.each do |section| + flex.with_row( + data: draggable_item_config(section) + ) do + render(row_component_class.new(user_custom_field_section: section, first_and_last:)) + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/index_component.rb b/app/components/settings/user_custom_field_sections/index_component.rb new file mode 100644 index 000000000000..24fddff4f476 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/index_component.rb @@ -0,0 +1,76 @@ +# 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 + module UserCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_sections:) + super + + @user_custom_field_sections = user_custom_field_sections + end + + def row_component_class + Settings::UserCustomFieldSections::ShowComponent + end + + def first_and_last + [@user_custom_field_sections.first, @user_custom_field_sections.last] + end + + private + + def wrapper_data_attributes + { + controller: "generic-drag-and-drop" + } + end + + def drop_target_config + { + generic_drag_and_drop_target: "container", + "target-allowed-drag-type": "section" + } + end + + def draggable_item_config(section) + { + "draggable-id": section.id, + "draggable-type": "section", + "drop-url": drop_admin_settings_user_custom_field_section_path(section) + } + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb new file mode 100644 index 000000000000..48fddf2228c8 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb @@ -0,0 +1,10 @@ +<%= render( + Primer::Alpha::Dialog.new( + title: t("settings.user_custom_fields.label_new_section"), + size: :medium_portrait, + id: MODAL_ID, + data: { "keep-open-on-submit": true } + ) + ) do %> + <%= render(Settings::UserCustomFieldSections::DialogBodyFormComponent.new) %> +<% end %> diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb new file mode 100644 index 000000000000..e91c9bae363b --- /dev/null +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.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 Settings + module UserCustomFieldSections + class NewSectionDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + MODAL_ID = "user-custom-field-section-dialog" + FORM_ID = "user-custom-field-section-dialog-form" + end + end +end diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb new file mode 100644 index 000000000000..55234ff31a83 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -0,0 +1,120 @@ +<%= + component_wrapper(class: "op-user-custom-field-section-container", data: { test_selector: "user-custom-field-section-container-#{@user_custom_field_section.id}" }) do + render(border_box_container(mt: 3, data: drag_and_drop_target_config)) do |component| + component.with_header(font_weight: :bold) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + @user_custom_field_section.name.presence || t("settings.user_custom_fields.label_untitled_section") + end + end + end + + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-section-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(".label_section_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + if @ordered_attributes.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + end + + menu.with_divider + + menu.with_sub_menu_item( + label: t("settings.user_custom_fields.label_new_attribute"), + content_arguments: { + aria: { label: t(".label_add_attribute") }, + data: { test_selector: "new-user-custom-field-in-section-submenu" } + } + ) do |sub_menu| + sub_menu.with_leading_visual_icon(icon: :plus) + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(sub_menu, format) + end + end + end + end + + actions_container.with_column do + render( + Primer::Alpha::Dialog.new( + id: "user-custom-field-section-dialog#{@user_custom_field_section.id}", title: t("settings.user_custom_fields.label_new_section"), + size: :medium_portrait, + data: { "keep-open-on-submit": true } + ) + ) do |_dialog| + render(Settings::UserCustomFieldSections::DialogBodyFormComponent.new(user_custom_field_section: @user_custom_field_section)) + end + end + end + end + end + if @ordered_attributes.empty? + component.with_row(data: { "empty-list-item": true, "filter--filter-list-target": "searchItem" }) do + flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| + empty_list_container.with_column(ml: 4, mr: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t(".label_no_user_custom_fields") } + end + empty_list_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "new-user-custom-field-in-section-button" })) do |menu| + menu.with_show_button( + scheme: :secondary, + "aria-label": t("settings.user_custom_fields.label_new_attribute") + ) do |btn| + btn.with_leading_visual_icon(icon: :plus) + btn.with_trailing_visual_icon(icon: :"triangle-down") + + t("settings.user_custom_fields.label_new_attribute") + end + + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(menu, format) + end + end + end + end + end + else + @ordered_attributes.each_with_index do |key, index| + first = index == 0 + last = index == @ordered_attributes.size - 1 + + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + component.with_row(data: draggable_item_config_for_built_in(key).merge("filter--filter-list-target": "searchItem")) do + render( + Settings::UserCustomFieldSections::BuiltInAttributeRowComponent.new( + section: @user_custom_field_section, + attribute_key: key, + first:, + last: + ) + ) + end + elsif (cf = @custom_fields_by_key[key]) + component.with_row(data: draggable_item_config_for_custom_field(cf).merge("filter--filter-list-target": "searchItem")) do + render( + custom_field_row_component_class.new( + user_custom_field: cf, + first:, + last: + ) + ) + end + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/show_component.rb b/app/components/settings/user_custom_field_sections/show_component.rb new file mode 100644 index 000000000000..a685cb715e9d --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.rb @@ -0,0 +1,173 @@ +# 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 + module UserCustomFieldSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_section:, first_and_last: []) + super + + @user_custom_field_section = user_custom_field_section + @ordered_attributes = user_custom_field_section.attribute_order + @custom_fields_by_key = user_custom_field_section.custom_fields_by_key + + @first_and_last = first_and_last + end + + def custom_field_row_component_class + Settings::UserCustomFieldSections::CustomFieldRowComponent + end + + private + + def wrapper_uniq_by + @user_custom_field_section.id + end + + def drag_and_drop_target_config + { + generic_drag_and_drop_target: "container", + "target-container-accessor": ".Box > ul", + "target-id": @user_custom_field_section.id, + "target-allowed-drag-type": "user-field" + } + end + + def draggable_item_config_for_custom_field(user_custom_field) + { + "draggable-id": user_custom_field.id, + "draggable-type": "user-field", + "drop-url": drop_admin_settings_user_custom_field_path(user_custom_field) + } + end + + def draggable_item_config_for_built_in(key) + { + "draggable-id": key, + "draggable-type": "user-field", + "drop-url": drop_admin_settings_user_custom_field_section_built_in_attribute_path( + @user_custom_field_section, key + ) + } + end + + def move_actions(menu) + unless first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_user_custom_field_section_path(@user_custom_field_section, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, + test_selector: "user-custom-field-section-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def disabled_delete_action_item(menu) + menu.with_item(label: t("text_destroy"), disabled: true) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def edit_action_item(menu) + menu.with_item(label: t(".label_edit_section"), + tag: :button, + content_arguments: { + "data-show-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}", + "data-test-selector": "user-custom-field-section-edit" + }, + value: "") do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_user_custom_field_section_path(@user_custom_field_section), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t(:text_are_you_sure), + turbo_stream: true, + test_selector: "user-custom-field-section-delete" + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @user_custom_field_section + else + @user_custom_field_section.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @user_custom_field_section + else + @user_custom_field_section.last? + end + end + + def action_menu_item_for_custom_field_format(menu, format) + menu.with_item( + label: helpers.label_for_custom_field_format(format.name), + tag: :a, + href: new_admin_settings_user_custom_field_path( + field_format: format.name, + custom_field_section_id: @user_custom_field_section.id + ), + content_arguments: { data: { turbo: "false", + test_selector: "new-user-custom-field-in-section-button-#{format.name}" } } + ) + end + end + end +end diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.html.erb b/app/components/settings/user_custom_fields/edit_form_header_component.html.erb new file mode 100644 index 000000000000..158364331b15 --- /dev/null +++ b/app/components/settings/user_custom_fields/edit_form_header_component.html.erb @@ -0,0 +1,8 @@ +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + helpers.render_tab_header_nav(header, tabs, test_selector: :user_attribute_detail_header) + end +%> diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.rb b/app/components/settings/user_custom_fields/edit_form_header_component.rb new file mode 100644 index 000000000000..a2918f2ac7d9 --- /dev/null +++ b/app/components/settings/user_custom_fields/edit_form_header_component.rb @@ -0,0 +1,88 @@ +# 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 + module UserCustomFields + class EditFormHeaderComponent < ApplicationComponent + def initialize(custom_field:, selected:) + super + @custom_field = custom_field + end + + def tabs + tabs = [ + { + name: "user_custom_field_edit", + path: edit_admin_settings_user_custom_field_path(@custom_field), + label: t(:label_details) + } + ] + + if @custom_field.hierarchical_list? + tabs << { + name: "items", + path: admin_settings_user_custom_field_items_path(@custom_field), + label: t(:label_item_plural) + } + elsif @custom_field.list? + tabs << { + name: "items", + path: list_items_admin_settings_user_custom_field_path(@custom_field), + label: t(:label_item_plural) + } + end + + tabs << + { + name: "attribute_help_text", + path: attribute_help_text_admin_settings_user_custom_field_path(@custom_field), + label: AttributeHelpText.human_attribute_name(:help_text) + } + + tabs + end + + def page_title + concat @custom_field.attribute_in_database("name") + concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(@custom_field.field_format)})" } + end + + def breadcrumbs_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_custom_fields.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(@custom_field.field_format), + @custom_field.attribute_in_database("name")) + ] + end + end + end +end diff --git a/app/components/settings/user_custom_fields/header_component.html.erb b/app/components/settings/user_custom_fields/header_component.html.erb new file mode 100644 index 000000000000..441407f59ee4 --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -0,0 +1,72 @@ +<%= component_wrapper do %> + <%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title(variant: :default) { t("settings.user_custom_fields.heading") } + header.with_description { t(".heading_description") } + header.with_breadcrumbs(breadcrumbs_items) + end + %> + + <%= + render Primer::OpenProject::SubHeader.new(collapsed_search: true) do |subheader| + subheader.with_filter_input( + name: "attribute-filter-input", + label: t(".attribute_filter.label"), + visually_hide_label: true, + placeholder: t(".attribute_filter.label"), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: "attribute-filter-clear-button", + data: { + action: "input->filter--filter-list#filterLists", + "filter--filter-list-target": "filter" + } + ) + subheader.with_action_menu( + leading_icon: :plus, + trailing_icon: :"triangle-down", + label: I18n.t(:button_add), + anchor_align: :end, + button_arguments: { + scheme: :primary, + aria: { label: I18n.t(:button_add) }, + test_selector: "user-attributes-add-menu-button" + } + ) do |menu| + menu.with_item( + label: t("settings.user_custom_fields.label_new_section"), + id: "dialog-show-user-custom-field-section-dialog", + tag: :a, + href: new_link_admin_settings_user_custom_field_sections_path, + content_arguments: { data: { controller: "async-dialog", test_selector: "add-user-custom-field-section" } } + ) + + if allow_custom_field_creation? + menu.with_sub_menu_item( + label: t("settings.user_custom_fields.label_new_attribute"), + content_arguments: { data: { test_selector: "add-user-custom-field-attribute" } } + ) do |sub_menu| + OpenProject::CustomFieldFormat.enabled_for_class_name("User") + .sort_by(&:name) + .map do |format| + sub_menu.with_item( + label: helpers.label_for_custom_field_format(format.name), + tag: :a, + href: new_admin_settings_user_custom_field_path(field_format: format.name), + content_arguments: { data: { turbo: "false", + test_selector: "new-user-custom-field-button" } } + ) do |item| + unless format.enterprise_feature_allowed? + item.with_trailing_visual_icon(icon: :"op-enterprise-addons", classes: "upsell-colored") + end + end + end + end + end + end + end + %> +<% end %> diff --git a/app/components/settings/user_custom_fields/header_component.rb b/app/components/settings/user_custom_fields/header_component.rb new file mode 100644 index 000000000000..f58a704a87b7 --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFields + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + include CustomFieldsHelper + + def initialize(allow_custom_field_creation:) + super + + @allow_custom_field_creation = allow_custom_field_creation + end + + def breadcrumbs_items + [{ href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + t("settings.user_custom_fields.heading")] + end + + def allow_custom_field_creation? + @allow_custom_field_creation + end + end + end +end diff --git a/app/components/settings/user_custom_fields/new_form_header_component.html.erb b/app/components/settings/user_custom_fields/new_form_header_component.html.erb new file mode 100644 index 000000000000..5dd415ed723c --- /dev/null +++ b/app/components/settings/user_custom_fields/new_form_header_component.html.erb @@ -0,0 +1,6 @@ +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: :normal) + end +%> diff --git a/app/components/settings/user_custom_fields/new_form_header_component.rb b/app/components/settings/user_custom_fields/new_form_header_component.rb new file mode 100644 index 000000000000..2ce044da1118 --- /dev/null +++ b/app/components/settings/user_custom_fields/new_form_header_component.rb @@ -0,0 +1,50 @@ +# 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 + module UserCustomFields + class NewFormHeaderComponent < ApplicationComponent + def page_title + concat t("settings.user_custom_fields.new.heading") + concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(model.field_format)})" } + end + + def breadcrumb_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_custom_fields.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format), + t("settings.user_custom_fields.new.heading")) + ] + end + end + end +end diff --git a/app/components/users/form/custom_field_field_component.html.erb b/app/components/users/form/custom_field_field_component.html.erb new file mode 100644 index 000000000000..b23e7671104b --- /dev/null +++ b/app/components/users/form/custom_field_field_component.html.erb @@ -0,0 +1,5 @@ +<%= @form.fields_for_custom_fields :custom_field_values, @form.object, field_options do |cf_form| %> + <%= content_tag :div, class: css_classes do + cf_form.cf_form_field(container_class:) + end %> +<% end %> diff --git a/app/components/users/form/custom_field_field_component.rb b/app/components/users/form/custom_field_field_component.rb new file mode 100644 index 000000000000..2279a5904e4f --- /dev/null +++ b/app/components/users/form/custom_field_field_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 Users + module Form + class CustomFieldFieldComponent < ApplicationComponent + include ApplicationHelper + + def initialize(custom_field:, form:) + super() + @custom_field = custom_field + @form = form + end + + def field_options + { + custom_value: @form.object.custom_value_for(@custom_field), + custom_field: @custom_field + } + end + + def css_classes + ["form--field", @custom_field.attribute_name, required_class].compact + end + + def container_class + case @custom_field.field_format + when "text" then "-xxwide" + when "date" then "-xslim" + else "-middle" + end + end + + private + + def required_class + "-required" if @custom_field.is_required? && !@custom_field.boolean? + end + end + end +end diff --git a/app/components/users/form/custom_field_section_component.html.erb b/app/components/users/form/custom_field_section_component.html.erb new file mode 100644 index 000000000000..241e5c33ae26 --- /dev/null +++ b/app/components/users/form/custom_field_section_component.html.erb @@ -0,0 +1,11 @@ +
+ <%= title %> + + <% @section.attribute_order.each do |key| %> + <% if built_in?(key) %> + <%= render "users/form/built_in_fields/#{key}", f: @form, contract: @contract, user: @user %> + <% elsif (cf = visible_custom_field(key)) %> + <%= render Users::Form::CustomFieldFieldComponent.new(custom_field: cf, form: @form) %> + <% end %> + <% end %> +
diff --git a/app/components/users/form/custom_field_section_component.rb b/app/components/users/form/custom_field_section_component.rb new file mode 100644 index 000000000000..9ab2bf9da8de --- /dev/null +++ b/app/components/users/form/custom_field_section_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module Form + class CustomFieldSectionComponent < ApplicationComponent + def initialize(section:, form:, contract:, user:) + super() + @section = section + @form = form + @contract = contract + @user = user + @visible_cfs_by_key = visible_cfs_by_key(section) + end + + def title + @section.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section") + end + + def built_in?(key) + UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + end + + def visible_custom_field(key) + @visible_cfs_by_key[key] + end + + private + + def visible_cfs_by_key(section) + section.custom_fields.visible(User.current).index_by(&:column_name) + end + end + end +end diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb index 670f889f8d48..51106f72d318 100644 --- a/app/components/users/hover_card_component.html.erb +++ b/app/components/users/hover_card_component.html.erb @@ -57,14 +57,27 @@ See COPYRIGHT and LICENSE files for more details. end end + card_sections_with_fields.flat_map(&:last).each do |custom_field| + value = @user.formatted_custom_value_for(custom_field) + + flex.with_row(data: { test_selector: "user-hover-card-custom-field" }) do + flex_layout(classes: "op-user-hover-card--custom-field") do |cf| + cf.with_column(color: :muted, mr: 2) { custom_field.name } + cf.with_column do + custom_field.multi_value? ? render_value_labels(value) : value + end + end + end + end + flex.with_row do flex_layout(classes: "op-user-hover-card--group-list") do |f| f.with_column do - render(Primer::Beta::Octicon.new(icon: :people)) + render(Primer::Beta::Octicon.new(icon: :people, color: :muted)) end f.with_column do - render(Primer::Beta::Text.new(color: :muted, data: { test_selector: "user-hover-card-groups" })) do + render(Primer::Beta::Text.new(data: { test_selector: "user-hover-card-groups" })) do group_membership_summary end end diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index 014639f18510..9cfb4fef999e 100644 --- a/app/components/users/hover_card_component.rb +++ b/app/components/users/hover_card_component.rb @@ -31,6 +31,8 @@ class Users::HoverCardComponent < ApplicationComponent include OpPrimer::ComponentHelpers + MULTI_VALUE_DISPLAY_LIMIT = 3 + def initialize(id:) super @@ -60,8 +62,31 @@ def group_membership_summary(max_length = 40) build_summary(group_links, cutoff_index) end + def card_sections_with_fields + @card_sections_with_fields ||= UserCustomFieldSection.with_filled_fields_for(@user, visible_on_user_card: true) + end + private + # Renders the values of a multi-value field as a row of Primer labels, + # capped at MULTI_VALUE_DISPLAY_LIMIT with an "and N more" overflow hint. + def render_value_labels(value) + values = Array(value) + remaining = values.size - MULTI_VALUE_DISPLAY_LIMIT + + labels = values.first(MULTI_VALUE_DISPLAY_LIMIT).map do |item| + render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } + end + + if remaining > 0 + labels << render(Primer::Beta::Text.new) do + t("custom_fields.multi_value_more", count: remaining) + end + end + + safe_join(labels, " ") + end + def linked_group_names(groups) groups.map { |group| link_to(h(group.name), show_group_path(group)) } end diff --git a/app/components/users/profile/attributes_component.html.erb b/app/components/users/profile/attributes_component.html.erb deleted file mode 100644 index 0b0c51447258..000000000000 --- a/app/components/users/profile/attributes_component.html.erb +++ /dev/null @@ -1,34 +0,0 @@ -<%= - render(Primer::OpenProject::SidePanel::Section.new) do |section| - section.with_title { t(:label_information) } - - component_wrapper do - flex_layout do |details_container| - if user_is_allowed_to_see_email - details_container.with_row(font_weight: :bold) do - User.human_attribute_name(:mail) - end - - details_container.with_row do - mail_to(escape_javascript(@user.mail), nil, encode: "javascript") - end - end - - visible_custom_fields.each do |custom_field| - details_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do - custom_field.name - end - - details_container.with_row do - value = @user.formatted_custom_value_for(custom_field) - if value.is_a?(Array) - safe_join(value, ", ") - else - value - end - end - end - end - end - end -%> diff --git a/app/components/users/profile/attributes_section_component.html.erb b/app/components/users/profile/attributes_section_component.html.erb new file mode 100644 index 000000000000..74a455e45c56 --- /dev/null +++ b/app/components/users/profile/attributes_section_component.html.erb @@ -0,0 +1,21 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new) do |section| + section.with_title { section_title } + + flex_layout do |container| + attributes.each do |attribute| + container.with_row(mt: 2, font_weight: :bold, test_selector: "user-profile-attribute") do + attribute.label + end + + container.with_row do + if attribute.multi_value? + safe_join(attribute.value.map { |item| render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } }, " ") + else + attribute.value + end + end + end + end + end +%> diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_section_component.rb similarity index 70% rename from app/components/users/profile/attributes_component.rb rename to app/components/users/profile/attributes_section_component.rb index 50f7b10ed196..59ad8b97c5b1 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_section_component.rb @@ -30,32 +30,29 @@ module Users module Profile - class AttributesComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable + # Renders a single user custom field section (its built-in attributes and custom + # fields, in attribute_order) as a side panel section. Empty sections are skipped + # by the panel via render?. + class AttributesSectionComponent < ApplicationComponent include OpPrimer::ComponentHelpers - def initialize(user:) + def initialize(section:, user:) super() + @section = section @user = user end def render? - user_is_allowed_to_see_email || @user.visible_custom_field_values.any? { it.value.present? } + attributes.any? end - def visible_custom_fields - @user - .visible_custom_field_values - .select { |cv| cv.value.present? } - .group_by(&:custom_field) - .keys - .sort_by(&:name) + def attributes + @attributes ||= SectionAttributes.for(section: @section, user: @user, current_user: User.current) end - def user_is_allowed_to_see_email - User.current == @user || User.current.allowed_globally?(:view_user_email) + def section_title + @section.name.presence || t("settings.user_custom_fields.label_untitled_section") end end end diff --git a/app/components/users/profile/section_attribute.rb b/app/components/users/profile/section_attribute.rb new file mode 100644 index 000000000000..21a1bd1b8ba7 --- /dev/null +++ b/app/components/users/profile/section_attribute.rb @@ -0,0 +1,48 @@ +# 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 Users + module Profile + # A single renderable profile attribute (built-in or custom field). + # `value` is a scalar for single values, or an Array for multi-value custom fields. + class SectionAttribute + attr_reader :label, :value + + def initialize(label:, value:) + @label = label + @value = value + end + + def multi_value? + value.is_a?(Array) + end + end + end +end diff --git a/app/components/users/profile/section_attributes.rb b/app/components/users/profile/section_attributes.rb new file mode 100644 index 000000000000..3fdf76bfcafd --- /dev/null +++ b/app/components/users/profile/section_attributes.rb @@ -0,0 +1,109 @@ +# 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 Users + module Profile + # Builds the ordered list of renderable SectionAttribute objects for a section, + # filtered by per-attribute visibility (mirroring API::V3::Users::UserRepresenter) + # and by value presence. Built-in keys resolve to user attributes; other keys + # resolve to the section's custom fields. + class SectionAttributes + include Redmine::I18n + + def self.for(section:, user:, current_user:) + new(section:, user:, current_user:).call + end + + def initialize(section:, user:, current_user:) + @section = section + @user = user + @current_user = current_user + end + + def call + cf_by_key = @section.custom_fields_by_key + + @section.attribute_order.filter_map do |key| + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + built_in_attribute(key) + elsif (custom_field = cf_by_key[key]) + custom_field_attribute(custom_field) + end + end + end + + private + + def built_in_attribute(key) + return unless built_in_visible?(key) + + value = built_in_value(key) + return if value.blank? + + SectionAttribute.new(label: User.human_attribute_name(key), value:) + end + + def custom_field_attribute(custom_field) + return unless custom_field.visible?(@current_user) + + value = @user.formatted_custom_value_for(custom_field) + return if value.blank? + + SectionAttribute.new(label: custom_field.name, value:) + end + + def built_in_value(key) + if key == "language" + @user.language.presence && translate_language(@user.language).first + else + @user.public_send(key) + end + end + + def built_in_visible?(key) + if key == "mail" + can_view_email? || can_manage? + else + can_manage? + end + end + + def can_manage? + @current_user.allowed_globally?(:manage_user) || + @current_user.allowed_globally?(:create_user) || + @current_user == @user + end + + def can_view_email? + @current_user.allowed_globally?(:view_user_email) + end + end + end +end diff --git a/app/components/users/profile/side_panel_component.html.erb b/app/components/users/profile/side_panel_component.html.erb index 0d426180cdef..d31800be3d9d 100644 --- a/app/components/users/profile/side_panel_component.html.erb +++ b/app/components/users/profile/side_panel_component.html.erb @@ -1,11 +1,7 @@ <%= component_wrapper do render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| - [ - Users::Profile::AttributesComponent.new(user: @user), - Users::Profile::GroupsComponent.new(user: @user), - Users::Profile::ProjectsComponent.new(user: @user) - ].each do |component| + sections.each do |component| panel.with_section(component) end end diff --git a/app/components/users/profile/side_panel_component.rb b/app/components/users/profile/side_panel_component.rb index c7accb543c84..86d29c75daea 100644 --- a/app/components/users/profile/side_panel_component.rb +++ b/app/components/users/profile/side_panel_component.rb @@ -40,6 +40,24 @@ def initialize(user:) @user = user end + + # Each entry becomes its own SidePanel section, so headings and separators + # are provided consistently by the panel rather than drawn by hand. + def sections + [ + *attributes_section_components, + Users::Profile::GroupsComponent.new(user: @user), + Users::Profile::ProjectsComponent.new(user: @user) + ] + end + + private + + def attributes_section_components + UserCustomFieldSection.includes(:custom_fields).map do |section| + Users::Profile::AttributesSectionComponent.new(section:, user: @user) + end + end end end end diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index e8d11e9d9ed5..4e11bb2c8a5a 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -53,6 +53,7 @@ class BaseContract < ::ModelContract attribute :possible_values attribute :regexp attribute :searchable + attribute :visible_on_user_card attribute :type def validate_non_true_for_some_formats diff --git a/app/contracts/user_custom_field_sections/base_contract.rb b/app/contracts/user_custom_field_sections/base_contract.rb new file mode 100644 index 000000000000..e136f67afb28 --- /dev/null +++ b/app/contracts/user_custom_field_sections/base_contract.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :name + attribute :position + attribute :type + end +end diff --git a/app/contracts/user_custom_field_sections/create_contract.rb b/app/contracts/user_custom_field_sections/create_contract.rb new file mode 100644 index 000000000000..ed0cd86c3fc6 --- /dev/null +++ b/app/contracts/user_custom_field_sections/create_contract.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class CreateContract < BaseContract + end +end diff --git a/app/contracts/user_custom_field_sections/delete_contract.rb b/app/contracts/user_custom_field_sections/delete_contract.rb new file mode 100644 index 000000000000..4ed0b2a9b816 --- /dev/null +++ b/app/contracts/user_custom_field_sections/delete_contract.rb @@ -0,0 +1,41 @@ +# 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 UserCustomFieldSections + class DeleteContract < BaseContract + validate :section_not_in_use + + def section_not_in_use + if model.custom_fields.exists? + errors.add(:base, :in_use) + end + end + end +end diff --git a/app/contracts/user_custom_field_sections/update_contract.rb b/app/contracts/user_custom_field_sections/update_contract.rb new file mode 100644 index 000000000000..504945fc415b --- /dev/null +++ b/app/contracts/user_custom_field_sections/update_contract.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class UpdateContract < BaseContract + end +end diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb index 3fb520adfa63..3b9f1f1ae167 100644 --- a/app/controllers/admin/settings/project_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -42,6 +42,7 @@ def create ) if call.success? + close_dialog_via_turbo_stream("##{Settings::ProjectCustomFieldSections::NewSectionDialogComponent::MODAL_ID}") update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else @@ -57,6 +58,7 @@ def update ) if call.success? + close_dialog_via_turbo_stream("#project-custom-field-section-dialog#{@project_custom_field_section.id}") update_section_via_turbo_stream(project_custom_field_section: call.result) else update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) @@ -72,7 +74,7 @@ def destroy update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -86,7 +88,7 @@ def move if call.success? update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -101,7 +103,7 @@ def drop update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams end @@ -112,6 +114,14 @@ def new_link private + # Show a danger toast with the action's hint (resolved relative to the + # controller/action), appending the service's error detail (e.g. why a + # non-empty section cannot be deleted) when present. + def render_section_error_via_turbo_stream(call) + message = [t(".error"), call.message].compact_blank.join(" ") + render_error_flash_message_via_turbo_stream(message:) + end + def set_project_custom_field_section @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 8b88b00eb250..48657fc76997 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -143,8 +143,8 @@ def unlink end def move - result = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( - move_to: params[:move_to]&.to_sym + result = CustomFields::MoveService.new(user: current_user, custom_field: @custom_field).call( + move_to: params.expect(:move_to) ) if result.success? @@ -159,7 +159,7 @@ def move end def drop - result = ::ProjectCustomFields::DropService.new(user: current_user, project_custom_field: @custom_field).call( + result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call( target_id: params[:target_id], position: params[:position] ) @@ -262,7 +262,7 @@ def find_custom_field_projects_to_link end def find_custom_field - @custom_field = ProjectCustomField.find(params[:id]) + @custom_field = ProjectCustomField.find(params.expect(:id)) end def drop_success_streams(call) diff --git a/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb b/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb new file mode 100644 index 000000000000..a2c0ed91c727 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.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. +#++ + +module Admin + module Settings + module UserCustomFieldSections + class BuiltInAttributesController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::UserCustomFields::ComponentStreams + + menu_item :user_custom_fields_settings + + before_action :set_section + before_action :set_attribute_key + + def move + @section.move_in_order(@key, params.expect(:move_to)&.to_sym) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + respond_with_turbo_streams + end + + def drop # rubocop:disable Metrics/AbcSize + new_section_id = params[:target_id]&.to_i + return head :bad_request if new_section_id.nil? + + if @section.id == new_section_id + @section.add_to_order(@key, position: params[:position]&.to_i) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + else + new_section = UserCustomFieldSection.find(new_section_id) + @section.remove_from_order(@key) + new_section.add_to_order(@key, position: params[:position]&.to_i) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + update_section_via_turbo_stream(user_custom_field_section: new_section.reload) + end + + respond_with_turbo_streams + end + + private + + def set_section + @section = UserCustomFieldSection.find(params.expect(:user_custom_field_section_id)) + end + + def set_attribute_key + @key = params.expect(:key) + head :unprocessable_entity unless UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(@key) + end + end + end + end +end diff --git a/app/controllers/admin/settings/user_custom_field_sections_controller.rb b/app/controllers/admin/settings/user_custom_field_sections_controller.rb new file mode 100644 index 000000000000..e057b0167c90 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections_controller.rb @@ -0,0 +1,137 @@ +# 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::Settings + class UserCustomFieldSectionsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::UserCustomFields::ComponentStreams + + before_action :set_user_custom_field_section, only: %i[update move drop destroy] + + def create + call = ::UserCustomFieldSections::CreateService.new(user: current_user).call( + user_custom_field_section_params.merge(position: 1) + ) + + if call.success? + close_dialog_via_turbo_stream("##{Settings::UserCustomFieldSections::NewSectionDialogComponent::MODAL_ID}") + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + update_section_dialog_body_form_via_turbo_stream(user_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def update + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + user_custom_field_section_params + ) + + if call.success? + close_dialog_via_turbo_stream("#user-custom-field-section-dialog#{@user_custom_field_section.id}") + update_section_via_turbo_stream(user_custom_field_section: call.result) + else + update_section_dialog_body_form_via_turbo_stream(user_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def destroy + call = ::UserCustomFieldSections::DeleteService.new(user: current_user, model: @user_custom_field_section).call + + if call.success? + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + render_section_error_via_turbo_stream(call) + end + + respond_with_turbo_streams + end + + def move + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + move_to: params.expect(:move_to)&.to_sym + ) + + if call.success? + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + render_section_error_via_turbo_stream(call) + end + + respond_with_turbo_streams + end + + def drop + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + position: params[:position].to_i + ) + + if call.success? + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + render_section_error_via_turbo_stream(call) + end + + respond_with_turbo_streams + end + + def new_link + respond_with_dialog Settings::UserCustomFieldSections::NewSectionDialogComponent.new + end + + private + + # Show a danger toast with the action's hint (resolved relative to the + # controller/action), appending the service's error detail (e.g. why a + # non-empty section cannot be deleted) when present. + def render_section_error_via_turbo_stream(call) + message = [t(".error"), call.message].compact_blank.join(" ") + render_error_flash_message_via_turbo_stream(message:) + end + + def set_user_custom_field_section + @user_custom_field_section = UserCustomFieldSection.find(params.expect(:id)) + end + + def allow_custom_field_creation? + UserCustomFieldSection.any? + end + + def user_custom_field_section_params + params.expect(user_custom_field_section: [:name]) + end + end +end diff --git a/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb new file mode 100644 index 000000000000..f02af815110c --- /dev/null +++ b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb @@ -0,0 +1,47 @@ +# 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 Settings + module UserCustomFields + module Hierarchy + class ItemsController < Admin::CustomFields::Hierarchy::ItemsBaseController + menu_item :user_custom_fields_settings + + private + + def find_custom_field + @custom_field = CustomField.hierarchy_root_and_children.find(params.expect(:user_custom_field_id)) + end + end + end + end + end +end diff --git a/app/controllers/admin/settings/user_custom_fields_controller.rb b/app/controllers/admin/settings/user_custom_fields_controller.rb new file mode 100644 index 000000000000..580cfe6a2af6 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_fields_controller.rb @@ -0,0 +1,152 @@ +# 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::Settings + class UserCustomFieldsController < ::Admin::SettingsController + include CustomFields::SharedActions + include CustomFields::AttributeHelpTextActions + include OpTurbo::ComponentStream + include FlashMessagesOutputSafetyHelper + include Admin::Settings::UserCustomFields::ComponentStreams + + menu_item :user_custom_fields_settings + + # rubocop:disable Rails/LexicallyScopedActionFilter + before_action :set_sections, only: %i[show index edit update move drop] + before_action :find_custom_field, + only: %i(show edit update destroy delete_option reorder_alphabetical + move drop attribute_help_text update_attribute_help_text list_items) + before_action :prepare_custom_option_position, only: %i(update create) + before_action :find_custom_option, only: :delete_option + before_action :find_or_initialize_attribute_help_text, only: %i[attribute_help_text update_attribute_help_text] + # rubocop:enable Rails/LexicallyScopedActionFilter + + def index + @allow_custom_field_creation = @user_custom_field_sections.any? + + respond_to :html + end + + def show + render :edit + end + + def new + @custom_field = UserCustomField.new(custom_field_section_id: params[:custom_field_section_id], + field_format: params[:field_format]) + + respond_to :html + end + + def edit; end + + def list_items; end + + def move + result = CustomFields::MoveService.new(user: current_user, custom_field: @custom_field).call( + move_to: params.expect(:move_to) + ) + + if result.success? + update_sections_via_turbo_stream(user_custom_field_sections: @user_custom_field_sections) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def drop + result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call( + target_id: params[:target_id], + position: params[:position] + ) + + if result.success? + drop_success_streams(result) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def destroy + result = CustomFields::DeleteService.new(user: current_user, model: @custom_field).call + + if result.success? + update_section_via_turbo_stream(user_custom_field_section: @custom_field.user_custom_field_section.reload) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def attribute_help_text + render_attribute_help_text_form + end + + def update_attribute_help_text + update_help_text + end + + private + + def set_sections + @user_custom_field_sections = UserCustomFieldSection.includes(:custom_fields).all + end + + def find_custom_field + @custom_field = UserCustomField.find(params.expect(:id)) + end + + def drop_success_streams(call) + update_section_via_turbo_stream(user_custom_field_section: call.result[:current_section]) + if call.result[:section_changed] + update_section_via_turbo_stream(user_custom_field_section: call.result[:old_section]) + end + end + + def show_path + attribute_help_text_admin_settings_user_custom_field_path(@custom_field) + end + + def render_attribute_help_text_form(status: :ok) + render "custom_fields/attribute_help_texts/show_user", status: + end + end +end diff --git a/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb new file mode 100644 index 000000000000..180d1780410a --- /dev/null +++ b/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb @@ -0,0 +1,73 @@ +# 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 Settings + module UserCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_header_via_turbo_stream(allow_custom_field_creation:) + update_via_turbo_stream( + component: ::Settings::UserCustomFields::HeaderComponent.new( + allow_custom_field_creation: + ) + ) + end + + def update_section_via_turbo_stream(user_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::ShowComponent.new( + user_custom_field_section: + ) + ) + end + + def update_section_dialog_body_form_via_turbo_stream(user_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::DialogBodyFormComponent.new( + user_custom_field_section: + ) + ) + end + + def update_sections_via_turbo_stream(user_custom_field_sections:) + replace_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::IndexComponent.new( + user_custom_field_sections: + ) + ) + end + end + end + end + end +end diff --git a/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb b/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb index f5d036f86610..ce5af9f72733 100644 --- a/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb +++ b/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb @@ -71,6 +71,8 @@ def attribute_help_text_class_for_custom_field AttributeHelpText::Project when WorkPackageCustomField AttributeHelpText::WorkPackage + when UserCustomField + AttributeHelpText::User else raise ArgumentError, "Unsupported custom field type: #{@custom_field.class}" end diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb index eba9a48284ec..2f15cff427e7 100644 --- a/app/controllers/concerns/custom_fields/shared_actions.rb +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -34,24 +34,33 @@ module SharedActions included do def index_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" admin_settings_project_custom_fields_path(**params) + when "UserCustomField" + admin_settings_user_custom_fields_path(**params) else custom_fields_path(**params) end end def edit_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" admin_settings_project_custom_field_path(**params) + when "UserCustomField" + edit_admin_settings_user_custom_field_path(**params) else edit_custom_field_path(**params) end end def list_item_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" list_items_admin_settings_project_custom_field_path(**params) + when "UserCustomField" + list_items_admin_settings_user_custom_field_path(**params) else list_items_custom_field_path(**params) end diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index 70bc7741c7d3..007f530ed82c 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -48,7 +48,7 @@ class CustomFieldsController < ApplicationController def index # loading wp cfs exclicity to allow for eager loading @custom_fields_by_type = CustomField - .where.not(type: ["WorkPackageCustomField", "ProjectCustomField"]) + .where.not(type: ["WorkPackageCustomField", "ProjectCustomField", "UserCustomField"]) .group_by { |f| f.class.name } @custom_fields_by_type["WorkPackageCustomField"] = WorkPackageCustomField.includes(:types).all @@ -89,8 +89,8 @@ def find_custom_field end def check_custom_field - # ProjectCustomFields now managed in a different UI - if @custom_field.nil? || @custom_field.type == "ProjectCustomField" + # ProjectCustomFields and UserCustomFields now managed in a different UI + if @custom_field.nil? || @custom_field.type.in?(%w[ProjectCustomField UserCustomField]) flash[:error] = "Invalid CF type" redirect_to action: :index end diff --git a/app/controllers/projects/creation_wizard_controller.rb b/app/controllers/projects/creation_wizard_controller.rb index f444065596b4..fb76b36dc36d 100644 --- a/app/controllers/projects/creation_wizard_controller.rb +++ b/app/controllers/projects/creation_wizard_controller.rb @@ -101,10 +101,8 @@ def load_sections_and_fields .where(creation_wizard: true) .select(:custom_field_id) - @custom_fields_by_section = @project - .available_custom_fields - .where(id: enabled_in_wizard_ids) - .group_by(&:project_custom_field_section) + scoped_fields = @project.available_custom_fields.where(id: enabled_in_wizard_ids) + @custom_fields_by_section = ProjectCustomFieldSection.grouped_in_order(scoped_fields).to_h end def find_current_section diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 4150b2da595d..162553b4cb1b 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -91,7 +91,7 @@ def eager_load_project_custom_field_data .includes(:custom_fields) .merge(ProjectCustomField.visible) .group(:id, "custom_fields.id") - .order(:position, :position_in_custom_field_section) + .order(:position) end def set_project_custom_field_section diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 58d9a81828be..f19a35f6f8e0 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -54,8 +54,8 @@ class DetailsForm < ApplicationForm label: I18n.t("activerecord.attributes.project_custom_field.custom_field_section"), required: true ) do |list| - ProjectCustomFieldSection.find_each do |cs| - list.option(value: cs.id, label: cs.name) + section_class_for_model.all.each do |cs| # rubocop:disable Rails/FindEach -- ordered by default_scope; find_each would override it + list.option(value: cs.id, label: cs.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section")) end end end @@ -199,6 +199,14 @@ class DetailsForm < ApplicationForm ) end + if show_on_user_card_field? + details_form.check_box( + name: :visible_on_user_card, + label: label(:visible_on_user_card), + caption: instructions(:visible_on_user_card) + ) + end + details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :default) end @@ -217,7 +225,11 @@ def instructions(field) end def show_section_field? - model.is_a?(ProjectCustomField) + 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? @@ -285,6 +297,10 @@ def show_editable_field? model.is_a?(UserCustomField) end + def show_on_user_card_field? + model.is_a?(UserCustomField) + end + def formula_suggestions operators = CustomField::CalculatedValue::MATH_OPERATORS_FOR_FORMULA # Hide % from the suggestions as it can be used as either modulo or percentage. diff --git a/app/forms/project_custom_field_sections/name_form.rb b/app/forms/project_custom_field_sections/name_form.rb index 54d94661962c..2486c805e348 100644 --- a/app/forms/project_custom_field_sections/name_form.rb +++ b/app/forms/project_custom_field_sections/name_form.rb @@ -35,7 +35,8 @@ class ProjectCustomFieldSections::NameForm < ApplicationForm placeholder: ProjectCustomFieldSection.human_attribute_name(:name), label: ProjectCustomFieldSection.human_attribute_name(:name), required: true, - autofocus: true + autofocus: true, + autocomplete: "off" ) end end diff --git a/app/forms/user_custom_field_sections/name_form.rb b/app/forms/user_custom_field_sections/name_form.rb new file mode 100644 index 000000000000..f1bb32ec6367 --- /dev/null +++ b/app/forms/user_custom_field_sections/name_form.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. +#++ + +class UserCustomFieldSections::NameForm < ApplicationForm + form do |user_custom_field_section_form| + user_custom_field_section_form.text_field( + name: :name, + placeholder: UserCustomFieldSection.human_attribute_name(:name), + label: UserCustomFieldSection.human_attribute_name(:name), + required: true, + autofocus: true, + autocomplete: "off" + ) + end +end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index b5f68de591da..0eca25e194b5 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -49,12 +49,6 @@ def custom_fields_tabs path: custom_fields_path(tab: :VersionCustomField), label: :label_version_plural }, - { - name: "UserCustomField", - partial: "custom_fields/tab", - path: custom_fields_path(tab: :UserCustomField), - label: :label_user_plural - }, { name: "GroupCustomField", partial: "custom_fields/tab", diff --git a/app/models/attribute_help_text/user.rb b/app/models/attribute_help_text/user.rb new file mode 100644 index 000000000000..208c354ad550 --- /dev/null +++ b/app/models/attribute_help_text/user.rb @@ -0,0 +1,51 @@ +# 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. +#++ + +class AttributeHelpText::User < AttributeHelpText + def self.available_attributes + attributes = {} + + UserCustomField.find_each do |field| + attributes[field.attribute_name] = field.name + end + + attributes + end + + validates :attribute_name, inclusion: { in: ->(*) { available_attributes.keys } } + + def type_caption + ::User.model_name.human + end + + def self.visible_condition(_user) + ::AttributeHelpText.where(attribute_name: available_attributes.keys) + end +end diff --git a/app/models/custom_field/sectionable.rb b/app/models/custom_field/sectionable.rb new file mode 100644 index 000000000000..7f30e143d1c2 --- /dev/null +++ b/app/models/custom_field/sectionable.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Shared behaviour for custom fields that belong to a CustomFieldSection and +# maintain their position within that section via attribute_order. +# Include in UserCustomField and ProjectCustomField. +module CustomField::Sectionable + extend ActiveSupport::Concern + + included do + belongs_to :custom_field_section, class_name: "CustomFieldSection", + inverse_of: false + + validates :custom_field_section_id, presence: true + + after_create_commit :add_to_section_order + after_destroy_commit :remove_from_section_order + end + + def add_to_section_order + custom_field_section.add_to_order(column_name) + end + + def remove_from_section_order + section = custom_field_section + section.remove_from_order(column_name) unless section.nil? || section.frozen? + end +end diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb index 84f9db4f3d1b..854981534cca 100644 --- a/app/models/custom_field_section.rb +++ b/app/models/custom_field_section.rb @@ -48,4 +48,47 @@ def shown_in_overview_sidebar? def shown_in_overview_main_area? overview == OVERVIEW__MAIN_AREA_KEY end + + # Append key to the end of the ordered list (or at a specific 1-indexed position). + # Idempotent: removes any existing occurrence first. + def add_to_order(key, position: nil) + keys = attribute_order.reject { |k| k == key } + if position && position > 0 + keys.insert(position - 1, key) + else + keys << key + end + update_column(:attribute_order, keys) + end + + def remove_from_order(key) + update_column(:attribute_order, attribute_order.reject { |k| k == key }) + end + + def custom_fields_by_key + custom_fields.index_by(&:column_name) + end + + def custom_fields_in_order + cf_by_key = custom_fields_by_key + attribute_order.filter_map { |key| cf_by_key[key] } + end + + # move_to: :highest | :higher | :lower | :lowest + def move_in_order(key, move_to) + idx = attribute_order.index(key) + return unless idx + + keys = attribute_order.dup + keys.delete_at(idx) + new_idx = case move_to.to_sym + when :highest then 0 + when :lowest then keys.size + when :higher then [idx - 1, 0].max + when :lower then [idx + 1, keys.size].min + else return + end + keys.insert(new_idx, key) + update_column(:attribute_order, keys) + end end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 6934800f5192..2ea259685fb1 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -532,6 +532,7 @@ def self.permitted_attributes :custom_field_section_id, :allow_non_open_versions, :has_comment, + :visible_on_user_card, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } ], diff --git a/app/models/project/pdf_export/project_initiation.rb b/app/models/project/pdf_export/project_initiation.rb index cf39ae9ad79b..76cbc488945f 100644 --- a/app/models/project/pdf_export/project_initiation.rb +++ b/app/models/project/pdf_export/project_initiation.rb @@ -183,9 +183,10 @@ def enabled_in_wizard_ids .select(:custom_field_id) end - def collect_custom_fields_data + def collect_custom_fields_data # rubocop:disable Metrics/AbcSize project.available_custom_fields .where(id: enabled_in_wizard_ids) + .order("custom_fields.id") .group_by(&:project_custom_field_section) .map do |section, custom_fields| { diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 0017569b3718..00d4a0f1d649 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -29,18 +29,16 @@ #++ class ProjectCustomField < CustomField + include CustomField::Sectionable + belongs_to :project_custom_field_section, class_name: "ProjectCustomFieldSection", foreign_key: :custom_field_section_id, inverse_of: :custom_fields has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :project_custom_field has_many :projects, through: :project_custom_field_project_mappings - acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] - after_save :activate_required_field_in_all_projects, if: :is_for_all? - validates :custom_field_section_id, presence: true - # Relevant for user fields to allow membership assignment has_one :custom_fields_role, foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :custom_field has_one :role, through: :custom_fields_role diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index a0f019139557..e2f6c9a2f879 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -29,9 +29,33 @@ #++ class ProjectCustomFieldSection < CustomFieldSection - has_many :custom_fields, -> { order(position_in_custom_field_section: :asc) }, + has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy, foreign_key: :custom_field_section_id, inverse_of: :project_custom_field_section + + # Returns [[section, [ordered_cfs]], ...] for the given project's available, + # visible custom fields. Sections are ordered by position, fields within each + # section by attribute_order. Two SQL queries: one for the CFs, one for the sections. + def self.with_available_fields_for(project) + grouped_in_order(project.available_custom_fields) + end + + # Groups the given custom fields by their section, with sections ordered by + # position and the fields within each section ordered by attribute_order. + # Sections that end up with no ordered field are dropped. + # Returns [[section, [ordered_cfs]], ...]. Two SQL queries: the given relation + # is materialised once, then the relevant sections are loaded. + def self.grouped_in_order(custom_fields) + cfs_by_section_id = custom_fields.group_by(&:custom_field_section_id) + + where(id: cfs_by_section_id.keys) + .order(:position) + .filter_map do |section| + cf_by_key = cfs_by_section_id[section.id].index_by(&:column_name) + ordered = section.attribute_order.filter_map { |key| cf_by_key[key] } + [section, ordered] if ordered.any? + end + end end diff --git a/app/models/projects/custom_fields.rb b/app/models/projects/custom_fields.rb index f7fa007a7a0d..ebea9bda2f1d 100644 --- a/app/models/projects/custom_fields.rb +++ b/app/models/projects/custom_fields.rb @@ -63,7 +63,7 @@ def all_available_custom_fields RequestStore.fetch("#{self.class}#all_available_custom_fields") do ProjectCustomField .includes(:project_custom_field_section) - .order("custom_field_sections.position", :position_in_custom_field_section) + .order("custom_field_sections.position") end end diff --git a/app/models/user_custom_field.rb b/app/models/user_custom_field.rb index f9cc648a8eda..df296a39a792 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -29,6 +29,11 @@ #++ class UserCustomField < CustomField + include CustomField::Sectionable + + belongs_to :user_custom_field_section, class_name: "UserCustomFieldSection", foreign_key: :custom_field_section_id, + inverse_of: :custom_fields + scopes :visible def type_name diff --git a/app/models/user_custom_field_section.rb b/app/models/user_custom_field_section.rb new file mode 100644 index 000000000000..3227c021968f --- /dev/null +++ b/app/models/user_custom_field_section.rb @@ -0,0 +1,81 @@ +# 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. +#++ + +class UserCustomFieldSection < CustomFieldSection + BUILT_IN_ATTRIBUTES = %w[login firstname lastname mail language].freeze + + has_many :custom_fields, + class_name: "UserCustomField", + dependent: :restrict_with_exception, + foreign_key: :custom_field_section_id, + inverse_of: :user_custom_field_section + + # Returns sections that contain at least one of the given custom field IDs, + # ordered by section position. Field ordering within each section is driven + # by attribute_order in Ruby. + scope :with_custom_fields, ->(ids) { + joins(:custom_fields) + .where(custom_fields: { id: ids }) + .includes(:custom_fields) + .order("custom_field_sections.position") + } + + # Returns [[section, [ordered_cfs]], ...] for the given user's filled, visible + # custom fields. Pass visible_on_user_card: true to restrict to hover-card fields. + # Two SQL queries: one for sections, one for the relevant CFs (both use filled_cf_ids + # as a subquery so no values are materialised into Ruby memory). + def self.with_filled_fields_for(user, visible_on_user_card: nil) # rubocop:disable Metrics/AbcSize + cf_scope = UserCustomField.visible(User.current) + cf_scope = cf_scope.where(visible_on_user_card: true) if visible_on_user_card + + filled_cf_ids = user.custom_values + .where(custom_field: cf_scope) + .where.not(value: [nil, ""]) + .select(:custom_field_id) + + sections = joins(:custom_fields) + .where(custom_fields: { id: filled_cf_ids }) + .distinct + .order(:position) + + cfs_by_section = cf_scope.where(id: filled_cf_ids) + .group_by(&:custom_field_section_id) + + sections.filter_map do |section| + cf_by_key = (cfs_by_section[section.id] || []).index_by(&:column_name) + ordered = section.attribute_order.filter_map { |key| cf_by_key[key] } + [section, ordered] if ordered.any? + end + end + + def untitled? + name.blank? + end +end diff --git a/app/seeders/basic_data/user_custom_field_section_seeder.rb b/app/seeders/basic_data/user_custom_field_section_seeder.rb new file mode 100644 index 000000000000..ad6f40df824c --- /dev/null +++ b/app/seeders/basic_data/user_custom_field_section_seeder.rb @@ -0,0 +1,52 @@ +# 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 BasicData + class UserCustomFieldSectionSeeder < Seeder + def seed_data! + # The default section is intentionally untitled — it renders the I18n fallback + # label in the UI. The name validation is bypassed to match the migration that + # creates the same section for existing installations. + section = UserCustomFieldSection.new( + position: 1, + attribute_order: UserCustomFieldSection::BUILT_IN_ATTRIBUTES + ) + section.save!(validate: false) + end + + def applicable? + UserCustomFieldSection.none? + end + + def not_applicable_message + "Skipping user custom field section as there are already some configured" + end + end +end diff --git a/app/seeders/standard/basic_data_seeder.rb b/app/seeders/standard/basic_data_seeder.rb index 4f81b3066af0..824643889986 100644 --- a/app/seeders/standard/basic_data_seeder.rb +++ b/app/seeders/standard/basic_data_seeder.rb @@ -47,7 +47,8 @@ def data_seeder_classes ::BasicData::WorkflowSeeder, ::BasicData::PrioritySeeder, ::BasicData::SettingSeeder, - ::BasicData::ProjectCustomFieldSectionSeeder + ::BasicData::ProjectCustomFieldSectionSeeder, + ::BasicData::UserCustomFieldSectionSeeder ] end end diff --git a/app/services/project_custom_fields/drop_service.rb b/app/services/custom_fields/drop_service.rb similarity index 59% rename from app/services/project_custom_fields/drop_service.rb rename to app/services/custom_fields/drop_service.rb index c3501e7539af..ddba3925c492 100644 --- a/app/services/project_custom_fields/drop_service.rb +++ b/app/services/custom_fields/drop_service.rb @@ -28,18 +28,17 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectCustomFields +module CustomFields class DropService < ::BaseServices::BaseCallable - def initialize(user:, project_custom_field:) + def initialize(user:, custom_field:) super() @user = user - @project_custom_field = project_custom_field + @custom_field = custom_field end def perform service_call = validate_permissions service_call = perform_drop(service_call, params) if service_call.success? - service_call end @@ -52,44 +51,32 @@ def validate_permissions end def perform_drop(service_call, params) - begin - section_changed, current_section, old_section = check_and_update_section_if_changed(params) - update_position(params[:position]&.to_i) - - service_call.success = true - service_call.result = { section_changed:, current_section:, old_section: } - rescue StandardError => e - service_call.success = false - service_call.errors = e.message - end + section_changed, current_section, old_section = move_to_target_section(params) + current_section.add_to_order(@custom_field.column_name, position: params[:position]&.to_i) + service_call.success = true + service_call.result = { section_changed:, current_section: current_section.reload, old_section: } + service_call + rescue StandardError => e + service_call.success = false + service_call.errors = e.message service_call end private - def check_and_update_section_if_changed(params) - current_section = @project_custom_field.project_custom_field_section + def move_to_target_section(params) + current_section = @custom_field.custom_field_section new_section_id = params[:target_id]&.to_i - if current_section.id != new_section_id - old_section = current_section - current_section = update_section(new_section_id) - return [true, current_section, old_section] - end - - [false, current_section, nil] - end + return [false, current_section, nil] if current_section.id == new_section_id - def update_section(new_section_id) - current_section = ProjectCustomFieldSection.find(new_section_id) - @project_custom_field.remove_from_list - @project_custom_field.update(project_custom_field_section: current_section) - current_section - end + old_section = current_section + current_section = CustomFieldSection.find(new_section_id) + old_section.remove_from_order(@custom_field.column_name) + @custom_field.update!(custom_field_section_id: current_section.id) - def update_position(new_position) - @project_custom_field.insert_at(new_position) + [true, current_section, old_section.reload] end end end diff --git a/app/services/custom_fields/move_service.rb b/app/services/custom_fields/move_service.rb new file mode 100644 index 000000000000..510fc5d736d4 --- /dev/null +++ b/app/services/custom_fields/move_service.rb @@ -0,0 +1,49 @@ +# 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 + class MoveService < ::BaseServices::BaseCallable + def initialize(user:, custom_field:) + super() + @user = user + @custom_field = custom_field + end + + def perform + return ServiceResult.failure(errors: { base: :error_unauthorized }) unless @user.admin? + + section = @custom_field.custom_field_section + section.move_in_order(@custom_field.column_name, params[:move_to]&.to_sym) + ServiceResult.success(result: section) + rescue StandardError => e + ServiceResult.failure(errors: e.message) + end + end +end diff --git a/app/services/user_custom_field_sections/create_service.rb b/app/services/user_custom_field_sections/create_service.rb new file mode 100644 index 000000000000..ff08f9302b43 --- /dev/null +++ b/app/services/user_custom_field_sections/create_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/user_custom_field_sections/delete_service.rb b/app/services/user_custom_field_sections/delete_service.rb new file mode 100644 index 000000000000..8a09d3b05409 --- /dev/null +++ b/app/services/user_custom_field_sections/delete_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/user_custom_field_sections/set_attributes_service.rb b/app/services/user_custom_field_sections/set_attributes_service.rb new file mode 100644 index 000000000000..2c5e6634d931 --- /dev/null +++ b/app/services/user_custom_field_sections/set_attributes_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/user_custom_field_sections/update_service.rb b/app/services/user_custom_field_sections/update_service.rb new file mode 100644 index 000000000000..a41e70a0d9a1 --- /dev/null +++ b/app/services/user_custom_field_sections/update_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/views/admin/settings/user_custom_fields/edit.html.erb b/app/views/admin/settings/user_custom_fields/edit.html.erb new file mode 100644 index 000000000000..5ac672e582ee --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/edit.html.erb @@ -0,0 +1,41 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :user_custom_field_edit + ) + ) +%> + +<%= render CustomFields::DetailsComponent.new(@custom_field) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb new file mode 100644 index 000000000000..a493d877c5ed --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemComponent.new(item: @active_item, custom_field: @custom_field, show_edit_form: true)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb new file mode 100644 index 000000000000..5442cb0d37bc --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb new file mode 100644 index 000000000000..ccf51d52126a --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item, new_item: @new_item)) %> diff --git a/app/views/admin/settings/user_custom_fields/index.html.erb b/app/views/admin/settings/user_custom_fields/index.html.erb new file mode 100644 index 000000000000..8f69bafa4a96 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/index.html.erb @@ -0,0 +1,38 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading") %> + +
+ <%= render(Settings::UserCustomFields::HeaderComponent.new(allow_custom_field_creation: @allow_custom_field_creation)) %> + <%= render( + Settings::UserCustomFieldSections::IndexComponent.new( + user_custom_field_sections: @user_custom_field_sections + ) + ) %> +
diff --git a/app/views/admin/settings/user_custom_fields/list_items.html.erb b/app/views/admin/settings/user_custom_fields/list_items.html.erb new file mode 100644 index 000000000000..6596320501d8 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/list_items.html.erb @@ -0,0 +1,76 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :user_custom_field_edit + ) + ) +%> + +<%= settings_primer_form_with( + model: @custom_field, + scope: :custom_field, + url: admin_settings_user_custom_field_path(@custom_field), + html: { method: :put, id: "custom_field_form" } + ) do |f| %> + <%= render partial: "custom_fields/custom_options", locals: { custom_field: @custom_field, f: f } %> + + <%= + flex_layout(mt: 4) do |flex| + flex.with_column do + render Primer::Beta::Button.new( + scheme: :secondary, + tag: :a, + href: reorder_alphabetical_admin_settings_user_custom_field_path(@custom_field), + data: { + turbo_method: :post, + turbo_confirm: t("custom_fields.reorder_confirmation") + }, + mr: 2 + ) do + t("custom_fields.reorder_alphabetical") + end + end + flex.with_column do + render Primer::Beta::Button.new( + scheme: :primary, + type: :submit + ) do |button| + button.with_leading_visual_icon(icon: :check) + t(:button_save) + end + end + end + %> +<% end %> diff --git a/app/views/admin/settings/user_custom_fields/new.html.erb b/app/views/admin/settings/user_custom_fields/new.html.erb new file mode 100644 index 000000000000..5b4e53dd66c8 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/new.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), t("settings.user_custom_fields.new.heading") %> + +<%= render(Settings::UserCustomFields::NewFormHeaderComponent.new(@custom_field)) %> + +<%= render CustomFields::DetailsComponent.new(@custom_field) %> diff --git a/app/views/custom_fields/attribute_help_texts/show_user.html.erb b/app/views/custom_fields/attribute_help_texts/show_user.html.erb new file mode 100644 index 000000000000..52058ce4e3d4 --- /dev/null +++ b/app/views/custom_fields/attribute_help_texts/show_user.html.erb @@ -0,0 +1,54 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, AttributeHelpText.human_plural_model_name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :attribute_help_text + ) + ) +%> + +<%= error_messages_for "attribute_help_text" %> + +
+ <%= + primer_form_with( + model: @attribute_help_text, + scope: :attribute_help_text, + url: update_attribute_help_text_admin_settings_user_custom_field_path(@custom_field), + method: :put + ) do |f| + render AttributeHelpTexts::Form.new(f, hide_attribute_name: true) + end + %> +
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index f7618bd0c9d9..7c55671ce628 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -30,13 +30,13 @@ See COPYRIGHT and LICENSE files for more details. <%= error_messages_for @user %>
- <%= render partial: "users/form/basic_attributes", locals: { f: f, contract: @contract } %> - <% if current_user.admin? %> <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render partial: "users/form/custom_fields", locals: { f: f } %> + <% UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- to honor default scope ordering %> + <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> + <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %>
diff --git a/app/views/users/_simple_form.html.erb b/app/views/users/_simple_form.html.erb index 19e9290b0c8d..3a46855ecac4 100644 --- a/app/views/users/_simple_form.html.erb +++ b/app/views/users/_simple_form.html.erb @@ -28,13 +28,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%>
- <%= render partial: "users/form/basic_attributes", locals: { f: f, contract: @contract } %> - <% if current_user.admin? %> <%= render partial: "users/form/authentication/auth_source", locals: { f: f } %> <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render partial: "users/form/custom_fields", locals: { f: f } %> + <% UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- to honor default scope ordering %> + <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> + <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %>
diff --git a/app/views/users/form/_basic_attributes.html.erb b/app/views/users/form/_basic_attributes.html.erb deleted file mode 100644 index 6abff46f0b15..000000000000 --- a/app/views/users/form/_basic_attributes.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -<%# locals: (f:, contract:) -%> - -<% unless @user.new_record? %> -
- <%= f.text_field :login, - required: true, - disabled: !contract.writable?(:login), - size: 25, - container_class: "-middle" %> -
-<% end %> - -
- <%= f.text_field :firstname, - required: true, - disabled: !contract.writable?(:firstname), - container_class: "-middle" %> -
- -
- <%= f.text_field :lastname, - required: true, - disabled: !contract.writable?(:lastname), - container_class: "-middle" %> -
- -
- <%= f.text_field :mail, - required: true, - disabled: !contract.writable?(:mail), - container_class: "-middle" %> -
- -
- <%= f.select :language, - lang_options_for_select, - prompt: "--- #{t(:actionview_instancetag_blank_option)} ---", - container_class: "-middle" %> -
diff --git a/app/views/users/form/_custom_fields.html.erb b/app/views/users/form/_custom_fields.html.erb deleted file mode 100644 index e829c3b6ad99..000000000000 --- a/app/views/users/form/_custom_fields.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= render partial: "customizable/form", - locals: { form: f, all_fields: true, only_required: false, input_size: "-middle" } %> diff --git a/app/views/users/form/built_in_fields/_firstname.html.erb b/app/views/users/form/built_in_fields/_firstname.html.erb new file mode 100644 index 000000000000..623344bcd621 --- /dev/null +++ b/app/views/users/form/built_in_fields/_firstname.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.text_field :firstname, + required: true, + disabled: !contract.writable?(:firstname), + container_class: "-middle" %> +
diff --git a/app/views/users/form/built_in_fields/_language.html.erb b/app/views/users/form/built_in_fields/_language.html.erb new file mode 100644 index 000000000000..ed8562925f05 --- /dev/null +++ b/app/views/users/form/built_in_fields/_language.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.select :language, + lang_options_for_select, + prompt: "--- #{t(:actionview_instancetag_blank_option)} ---", + container_class: "-middle" %> +
diff --git a/app/views/users/form/built_in_fields/_lastname.html.erb b/app/views/users/form/built_in_fields/_lastname.html.erb new file mode 100644 index 000000000000..e28ad9940a26 --- /dev/null +++ b/app/views/users/form/built_in_fields/_lastname.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.text_field :lastname, + required: true, + disabled: !contract.writable?(:lastname), + container_class: "-middle" %> +
diff --git a/app/views/users/form/built_in_fields/_login.html.erb b/app/views/users/form/built_in_fields/_login.html.erb new file mode 100644 index 000000000000..9630997392b0 --- /dev/null +++ b/app/views/users/form/built_in_fields/_login.html.erb @@ -0,0 +1,10 @@ +<%# locals: (f:, contract:, user:) -%> +<% unless user.new_record? %> +
+ <%= f.text_field :login, + required: true, + disabled: !contract.writable?(:login), + size: 25, + container_class: "-middle" %> +
+<% end %> diff --git a/app/views/users/form/built_in_fields/_mail.html.erb b/app/views/users/form/built_in_fields/_mail.html.erb new file mode 100644 index 000000000000..1d0245ce5226 --- /dev/null +++ b/app/views/users/form/built_in_fields/_mail.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.text_field :mail, + required: true, + disabled: !contract.writable?(:mail), + container_class: "-middle" %> +
diff --git a/config/initializers/custom_field_format.rb b/config/initializers/custom_field_format.rb index 07a693407a25..c58deb5e80a3 100644 --- a/config/initializers/custom_field_format.rb +++ b/config/initializers/custom_field_format.rb @@ -85,7 +85,7 @@ formats.register("hierarchy", label: :label_hierarchy, - only: %w(Project WorkPackage), + only: %w(Project User WorkPackage), order: 12, multi_value_possible: true, enterprise_feature: :custom_field_hierarchies, diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 51cd31e58b1f..c5faf0da902a 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -352,6 +352,12 @@ parent: :users_and_permissions, enterprise_feature: "placeholder_users" + menu.push :user_custom_fields_settings, + { controller: "/admin/settings/user_custom_fields", action: :index }, + if: ->(_) { User.current.admin? }, + caption: :label_user_attributes_plural, + parent: :users_and_permissions + menu.push :groups, { controller: "/groups" }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 0047a6dbccac..9ae73e2a892a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,6 +28,7 @@ --- en: + label_user_attributes_plural: "User attributes" no_results_title_text: There is currently nothing to display. activities: @@ -447,6 +448,20 @@ en:

Hello,

A new project has been created: projectValue:name

Thank you

+ project_custom_field_sections: + destroy: + error: "The section could not be deleted." + drop: + error: "The section could not be reordered." + move: + error: "The section could not be reordered." + user_custom_field_sections: + destroy: + error: "The section could not be deleted." + drop: + error: "The section could not be reordered." + move: + error: "The section could not be reordered." work_packages_identifier: 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. @@ -711,6 +726,8 @@ en: gain_role: Will gain role ‘%{new_role}’ lose_role: Will lose role ‘%{old_role}’ no_change: No changes + multi_value_more: + other: "and %{count} more" text_add_new_custom_field: > To add new custom fields to a project you first need to create them before you can add them to this project. @@ -755,6 +772,8 @@ en: project: "0 means no restriction" has_comment: project: "Allows the user to add a comment related to the project attribute when selecting the value in the project overview." + visible_on_user_card: + all: "When checked, this field's value is shown in the user information card when hovering over a user across the application." tab: no_results_title_text: There are currently no custom fields. @@ -1798,18 +1817,6 @@ en: activerecord: attributes: - work_package_semantic_alias: - identifier: "Identifier" - work_package: "Work package" - jira_import: - projects: "Projects" - "import/jira": - name: "Jira instance name" - url: "Jira instance URL" - personal_access_token: "Personal access token" - "import/jira_open_project_reference": - jira: "Jira" - jira_import: "Jira Migrator" announcements: show_until: "Display until" attachment: @@ -1855,6 +1862,7 @@ en: possible_values: "Possible values" regexp: "Regular expression" searchable: "Searchable" + visible_on_user_card: "Show on user card" admin_only: "Admin-only" has_comment: "Add a comment text field" custom_value: @@ -1889,6 +1897,23 @@ en: row_count: "Number of rows" column_count: "Number of columns" widgets: "Widgets" + group: + group_users: "Group users" + identity_url: "Identity URL" + organizational_unit: "Organizational unit" + parent: "Parent group" + group_detail: + organizational_unit: "Organizational unit" + parent: "Parent group" + "import/jira": + name: "Jira instance name" + personal_access_token: "Personal access token" + url: "Jira instance URL" + "import/jira_open_project_reference": + jira: "Jira" + jira_import: "Jira Migrator" + jira_import: + projects: "Projects" journal: notes: "Notes" cause_type: "Cause type" @@ -1917,6 +1942,21 @@ en: resource: "Resource" oauth_client: client: "Client ID" + ordered_persisted_query_entity: + entity: "Entity" + persisted_query: "Persisted query" + position: "Position" + persisted_query: + filters: "Filters" + name: "Name" + orders: "Orders" + selects: "Selects" + views: "Views" + persisted_view: + name: "Name" + parent: "Parent view" + public: "Public" + query: "Query" project: active_value: true: "unarchived" @@ -2063,14 +2103,8 @@ en: new_password: "New password" password_confirmation: "Confirmation" consented_at: "Consented at" - group: - identity_url: "Identity URL" - parent: "Parent group" - organizational_unit: "Organizational unit" - group_users: "Group users" - group_detail: - parent: "Parent group" - organizational_unit: "Organizational unit" + user_custom_field: + custom_field_section: Section user_preference: header_look_and_feel: "Look and feel" header_alerts: "Alerts" @@ -2186,7 +2220,9 @@ en: query: "Query" parent: "Parent view" public: "Public" - + work_package_semantic_alias: + identifier: "Identifier" + work_package: "Work package" errors: messages: accepted: "must be accepted." @@ -5675,6 +5711,7 @@ en: passwords: "Passwords" project_attributes: heading: "Project attributes" + label_add_attribute: "Add a project attribute" label_for_all_projects: "All projects" label_new_attribute: "Project attribute" label_new_section: "Section" @@ -5773,8 +5810,30 @@ en: session: "Session" user: default_preferences: "Default preferences" - display_format: "Display format" deletion: "Deletion" + display_format: "Display format" + user_custom_field_sections: + built_in_attribute_row_component: + label_built_in: "Built-in" + label_built_in_attribute_actions: "Built-in attribute actions" + custom_field_row_component: + label_user_custom_field_actions: "User attribute actions" + show_component: + label_add_attribute: "Add a user attribute" + label_edit_section: "Edit title" + label_no_user_custom_fields: "No user attributes defined in this section" + label_section_actions: "Section actions" + user_custom_fields: + header_component: + attribute_filter: + label: "Filter by attribute name…" + heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." + heading: "User attributes" + label_new_attribute: "User attribute" + label_new_section: "Section" + label_untitled_section: "Untitled section" + new: + heading: "New attribute" working_days: section_work_week: "Work week" section_holidays_and_closures: "Holidays and closures" diff --git a/config/routes.rb b/config/routes.rb index e1f69b32ed3c..848f047e01d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -765,6 +765,53 @@ get :new_link end end + + resources :user_custom_fields, controller: "/admin/settings/user_custom_fields" do + member do + delete "options/:option_id", action: "delete_option", as: :delete_option_of + post :reorder_alphabetical + put :move + put :drop + + get :attribute_help_text + put :update_attribute_help_text + + get :list_items + end + + resources :items, controller: "/admin/settings/user_custom_fields/hierarchy/items" do + member do + get :change_parent, action: :change_parent_dialog + post :change_parent, action: :change_parent + get :delete, action: :deletion_dialog + get :item_actions + post :move + get :new_child, action: :new + post :new_child, action: :create + end + end + end + + resources :user_custom_field_sections, controller: "/admin/settings/user_custom_field_sections", + only: %i[create update destroy] do + member do + put :move + put :drop + end + collection do + get :new_link + end + resources :built_in_attributes, + controller: "/admin/settings/user_custom_field_sections/built_in_attributes", + param: :key, + only: [] do + member do + put :move + put :drop + end + end + end + resource :working_days_and_hours, controller: "/admin/settings/working_days_and_hours_settings", only: %i[show update] do post :confirm_changes end diff --git a/db/migrate/20260528144922_create_default_user_custom_field_section.rb b/db/migrate/20260528144922_create_default_user_custom_field_section.rb new file mode 100644 index 000000000000..89f0e9d65199 --- /dev/null +++ b/db/migrate/20260528144922_create_default_user_custom_field_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateDefaultUserCustomFieldSection < ActiveRecord::Migration[8.1] + def up + section_id = execute(<<~SQL.squish).first["id"] + INSERT INTO custom_field_sections (type, name, position, created_at, updated_at) + VALUES ('UserCustomFieldSection', NULL, 1, NOW(), NOW()) + RETURNING id + SQL + + execute(<<~SQL.squish) + UPDATE custom_fields + SET custom_field_section_id = #{section_id} + WHERE type = 'UserCustomField' + AND custom_field_section_id IS NULL + SQL + end + + def down + execute(<<~SQL.squish) + UPDATE custom_fields + SET custom_field_section_id = NULL + WHERE type = 'UserCustomField' + AND custom_field_section_id IN ( + SELECT id FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' AND name IS NULL + ) + SQL + + execute(<<~SQL.squish) + DELETE FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' AND name IS NULL + SQL + end +end diff --git a/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb b/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb new file mode 100644 index 000000000000..ee5def0536cd --- /dev/null +++ b/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddVisibleOnUserCardToCustomFields < ActiveRecord::Migration[8.1] + def change + add_column :custom_fields, :visible_on_user_card, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb b/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb new file mode 100644 index 000000000000..09ce5f2d2392 --- /dev/null +++ b/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class AddAttributeOrderToCustomFieldSections < ActiveRecord::Migration[8.0] + def up + add_column :custom_field_sections, :attribute_order, :string, array: true, null: false, default: [] + # Populate attribute_order for every section from the existing position ordering. + # COALESCE handles sections that currently have no custom fields (ARRAY_AGG returns NULL). + execute(<<~SQL.squish) + UPDATE custom_field_sections cs + SET attribute_order = COALESCE( + ( + SELECT ARRAY_AGG('cf_' || cf.id::text ORDER BY cf.position_in_custom_field_section) + FROM custom_fields cf + WHERE cf.custom_field_section_id = cs.id + ), + '{}' + ) + WHERE cs.type = 'UserCustomFieldSection' + SQL + + # Prepend the five built-in user attributes to the first UserCustomFieldSection + # (the one with the lowest position). This is idempotent if run more than once. + execute(<<~SQL.squish) + UPDATE custom_field_sections + SET attribute_order = + ARRAY['login', 'firstname', 'lastname', 'mail', 'language'] || attribute_order + WHERE id = ( + SELECT id FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' + ORDER BY position + LIMIT 1 + ) + SQL + end + + def down + # Restore per-section positions from attribute_order in case they have been + # changed already. + # UNNEST … WITH ORDINALITY yields each key with its 1-based rank. + # Built-in keys (login, firstname, …) don't follow the cf_ pattern + # and are filtered out by the regex so the cast to integer is safe. + execute(<<~SQL.squish) + UPDATE custom_fields cf + SET position_in_custom_field_section = positions.ordinal + FROM ( + SELECT + SUBSTRING(key FROM 4)::integer AS cf_id, + ordinal::integer + FROM custom_field_sections cs, + LATERAL UNNEST(cs.attribute_order) WITH ORDINALITY AS unnested(key, ordinal) + WHERE cs.type = 'UserCustomFieldSection' + AND key ~ '^cf_[0-9]+$' + ) positions + WHERE cf.id = positions.cf_id + SQL + + remove_column :custom_field_sections, :attribute_order + end +end diff --git a/db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb b/db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb new file mode 100644 index 000000000000..04d1d461b2f7 --- /dev/null +++ b/db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class RemovePositionInCustomFieldSectionFromCustomFields < ActiveRecord::Migration[8.0] + def up + return unless column_exists?(:custom_fields, :position_in_custom_field_section) + + # Populate attribute_order for every section from the existing position ordering. + # COALESCE handles sections that currently have no custom fields (ARRAY_AGG returns NULL). + execute(<<~SQL.squish) + UPDATE custom_field_sections cs + SET attribute_order = COALESCE( + ( + SELECT ARRAY_AGG('cf_' || cf.id::text ORDER BY cf.position_in_custom_field_section) + FROM custom_fields cf + WHERE cf.custom_field_section_id = cs.id + ), + '{}' + ) + WHERE cs.type = 'ProjectCustomFieldSection' + SQL + + remove_column :custom_fields, :position_in_custom_field_section + end + + def down + add_column :custom_fields, :position_in_custom_field_section, :integer + + # Restore per-section positions from attribute_order in case they have been + # changed already. + # UNNEST … WITH ORDINALITY yields each key with its 1-based rank. + # Built-in keys (login, firstname, …) don't follow the cf_ pattern + # and are filtered out by the regex so the cast to integer is safe. + execute(<<~SQL.squish) + UPDATE custom_fields cf + SET position_in_custom_field_section = positions.ordinal + FROM ( + SELECT + SUBSTRING(key FROM 4)::integer AS cf_id, + ordinal::integer + FROM custom_field_sections cs, + LATERAL UNNEST(cs.attribute_order) WITH ORDINALITY AS unnested(key, ordinal) + WHERE cs.type = 'ProjectCustomFieldSection' + AND key ~ '^cf_[0-9]+$' + ) positions + WHERE cf.id = positions.cf_id + SQL + end +end diff --git a/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb b/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb index 0f86302c85bf..7b6223d83520 100644 --- a/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb +++ b/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb @@ -49,7 +49,7 @@ def _type delegate :id, :name, to: :represented def attributes - represented.custom_fields.map do |cf| + represented.custom_fields_in_order.map do |cf| convert_property(cf.attribute_name) end end diff --git a/lib/api/v3/projects/schemas/project_schema_representer.rb b/lib/api/v3/projects/schemas/project_schema_representer.rb index dd9e35ece611..13a2f45efa86 100644 --- a/lib/api/v3/projects/schemas/project_schema_representer.rb +++ b/lib/api/v3/projects/schemas/project_schema_representer.rb @@ -138,7 +138,7 @@ def project_custom_field_sections .includes(:custom_fields) .merge(ProjectCustomField.visible(current_user)) .group(:id, "custom_fields.id") - .order(:position, :position_in_custom_field_section) + .order(:position) end def section_representation(section) diff --git a/lib/custom_field_form_builder.rb b/lib/custom_field_form_builder.rb index 74b455567a15..f8abda9a9a12 100644 --- a/lib/custom_field_form_builder.rb +++ b/lib/custom_field_form_builder.rb @@ -78,11 +78,48 @@ def custom_field_input(options = {}) check_box(field, input_options.merge(checked: custom_value.strategy.checked?)) when "list" custom_field_input_list(field, input_options) + when "hierarchy" + custom_field_input_hierarchy(field, input_options) else text_field(field, input_options) end end + def custom_field_input_hierarchy(field, input_options) + root = custom_field.hierarchy_root + all_items = CustomFields::Hierarchy::HierarchicalItemService.new + .get_descendants(item: root, include_self: false) + .either(->(result) { result }, ->(_) { [] }) + + by_parent = all_items.group_by(&:parent_id) + top_level = by_parent[root&.id] || [] + + grouped = top_level.map do |top_item| + [hierarchy_item_label(top_item), hierarchy_collect_options(by_parent, top_item, depth: 0)] + end + + selected = Array(custom_value).map(&:value) + input_options[:multiple] = custom_field.multi_value? + + select(field, + template.grouped_options_for_select(grouped, selected), + custom_field_select_options_for_object, + input_options) + end + + def hierarchy_collect_options(by_parent, item, depth:) + indent = "  " * depth + children = by_parent[item.id] || [] + + [[hierarchy_item_label(item, indent:), item.id.to_s]] + + children.flat_map { |child| hierarchy_collect_options(by_parent, child, depth: depth + 1) } + end + + def hierarchy_item_label(item, indent: "") + base = item.short.present? ? "#{item.label} (#{item.short})" : item.label + "#{indent}#{base}" + end + def custom_field_input_list(field, input_options) customized = Array(custom_value).first&.customized selectable_options = custom_field_input_list_options(customized, custom_value) diff --git a/modules/grids/app/components/grids/project_attribute_widgets.rb b/modules/grids/app/components/grids/project_attribute_widgets.rb index 33f5f22cc039..f7f054ca58e9 100644 --- a/modules/grids/app/components/grids/project_attribute_widgets.rb +++ b/modules/grids/app/components/grids/project_attribute_widgets.rb @@ -55,9 +55,9 @@ def before_render def available_project_attributes_grouped_by_section @available_project_attributes_grouped_by_section ||= - @project.available_custom_fields - .group_by(&:project_custom_field_section) - .select { |section, _| section.shown_in_overview_main_area? } + ProjectCustomFieldSection + .with_available_fields_for(@project) + .select { |section, _| section.shown_in_overview_main_area? } end end end 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 0c51e5cfb554..84568832ec54 100644 --- a/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb +++ b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb @@ -18,8 +18,7 @@ class Engine < ::Rails::Engine parent: :authentication, after: :plugin_ldap_groups, caption: ->(*) { I18n.t("ldap_departments.label_menu_item") }, - enterprise_feature: "ldap_groups", - if: ->(*) { OpenProject::FeatureDecisions.departments_active? } + enterprise_feature: "ldap_groups" end add_cron_jobs do diff --git a/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb index 7311f2431462..b87087a781ca 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb @@ -50,9 +50,9 @@ def render? def available_project_custom_fields_grouped_by_section @available_project_custom_fields_grouped_by_section ||= - @project.available_custom_fields - .group_by(&:project_custom_field_section) - .select { |section, _| section.shown_in_overview_sidebar? } + ProjectCustomFieldSection + .with_available_fields_for(@project) + .select { |section, _| section.shown_in_overview_sidebar? } end end end diff --git a/spec/components/users/hover_card_component_spec.rb b/spec/components/users/hover_card_component_spec.rb index 3969d10c09f1..ac3067d4c402 100644 --- a/spec/components/users/hover_card_component_spec.rb +++ b/spec/components/users/hover_card_component_spec.rb @@ -164,4 +164,46 @@ end end end + + context "when displaying custom fields" do + let(:section) { create(:user_custom_field_section) } + let!(:cf) do + create(:user_custom_field, :string, name: "Job title", + user_custom_field_section: section, visible_on_user_card: true) + end + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: [build(:custom_value, custom_field: cf, value: "Developer")]) + end + + it "renders card-visible fields with their value" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Developer") + end + + context "with a multi-value field exceeding the display limit" do + let!(:multi_cf) do + create(:user_custom_field, :multi_list, + name: "Skills", + user_custom_field_section: section, + visible_on_user_card: true, + possible_values: %w[Ruby Rails React Vue Angular]) + end + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: multi_cf.possible_values.map { |opt| build(:custom_value, custom_field: multi_cf, value: opt) }) + end + + it "shows the first 3 values as labels and a count of the rest" do + field = find_test_selector("user-hover-card-custom-field") + + expect(field).to have_css("span.Label", text: "Ruby") + expect(field).to have_css("span.Label", text: "Rails") + expect(field).to have_css("span.Label", text: "React") + expect(field).to have_no_css("span.Label", text: "Vue") + expect(field).to have_text("and 2 more") + end + end + end end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb deleted file mode 100644 index 9c8d48f00691..000000000000 --- a/spec/components/users/profile/attributes_component_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -# 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 Users::Profile::AttributesComponent, type: :component do - shared_let(:logged_in_user) { create(:user) } - let(:user) { build(:user) } - let(:component) { described_class.new(user:) } - - current_user { logged_in_user } - - describe "render?" do - subject { component.render? } - - context "when user has view_user_email permission" do - before do - create(:standard_global_role) - end - - it { is_expected.to be(true) } - end - - context "when user views its own profile" do - current_user { user } - - it { is_expected.to be(true) } - end - - context "when user has no view_user_email permission" do - it { is_expected.to be(false) } - end - - context "when user has a custom field with a present value" do - let(:custom_field) { create(:user_custom_field, :string) } - let(:user) { build(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } - - it { is_expected.to be(true) } - end - - context "when user has a custom field with a blank value" do - let(:custom_field) { create(:user_custom_field, :string) } - let(:user) { build(:user, custom_values: [build(:custom_value, custom_field:, value: " ")]) } - - it { is_expected.to be(false) } - end - - context "when user has a non-visible custom field with a present value" do - let(:custom_field) { create(:user_custom_field, :string, admin_only: true) } - let(:user) { build(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } - - it { is_expected.to be(false) } - end - end - - describe "Custom field" do - let(:custom_field) { create(:user_custom_field, :string, admin_only:) } - let(:custom_values) do - [build(:custom_value, custom_field:, value: "Hello custom field")] - end - let(:admin_only) { false } - let(:user) { build_stubbed(:user, custom_values:) } - - current_user { build(:admin) } - - before do - render_inline(component) - end - - it "renders the field" do - expect(page).to have_text("Hello custom field") - end - - context "when not visible" do - let(:admin_only) { true } - - it "does not render the field" do - expect(page).to have_no_text("Hello custom field") - end - end - - context "with multiple custom fields" do - let(:list_custom_field) { create(:user_custom_field, :multi_list, admin_only:, name: "Ze list") } - let(:text_custom_field) { create(:user_custom_field, :text, admin_only:, name: "A portrait") } - let(:custom_values) do - [ - build(:custom_value, custom_field: list_custom_field, value: list_custom_field.possible_values[0]), - build(:custom_value, custom_field: list_custom_field, value: list_custom_field.possible_values[1]), - build(:custom_value, custom_field: list_custom_field, value: list_custom_field.possible_values[2]), - build(:custom_value, custom_field: text_custom_field, value: "This is **formatted** text.") - ] - end - - it "renders the fields correctly and sorted" do - # correct render of formattable - expect(page).to have_css("strong", text: "formatted") - # correct render of multi select - expect(page).to have_text("A, B, C") - # alphabetical order - items = page.all(:test_id, "user-custom-field") - expect(items[0]).to have_text("A portrait") - expect(items[1]).to have_text("Ze list") - end - end - end -end diff --git a/spec/components/users/profile/attributes_section_component_spec.rb b/spec/components/users/profile/attributes_section_component_spec.rb new file mode 100644 index 000000000000..38f0fed29c8a --- /dev/null +++ b/spec/components/users/profile/attributes_section_component_spec.rb @@ -0,0 +1,116 @@ +# 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 Users::Profile::AttributesSectionComponent, type: :component do + shared_let(:section) { create(:user_custom_field_section, name: "Profile info") } + let(:component) { described_class.new(section:, user:) } + + current_user { build(:admin) } + + context "with built-in and custom field attributes" do + shared_let(:custom_field) do + create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) + end + let(:user) do + create(:user, firstname: "Sarah", + custom_values: [build(:custom_value, custom_field:, value: "Developer")]) + end + + before do + section.update_column(:attribute_order, ["firstname", custom_field.column_name]) + render_inline(component) + end + + it "renders the section name as the title" do + expect(page).to have_text("Profile info") + end + + it "renders the built-in label and value" do + expect(page).to have_text(User.human_attribute_name("firstname")) + expect(page).to have_text("Sarah") + end + + it "renders the custom field label and value" do + expect(page).to have_text("Job title") + expect(page).to have_text("Developer") + end + end + + context "with a multi-value custom field" do + shared_let(:custom_field) do + create(:user_custom_field, :multi_list, name: "Skills", user_custom_field_section: section) + end + let(:user) do + create(:user, custom_values: custom_field.possible_values.first(3).map do |v| + build(:custom_value, custom_field:, value: v) + end) + end + + before do + section.update_column(:attribute_order, [custom_field.column_name]) + render_inline(component) + end + + it "renders each value as an accent label" do + expect(page).to have_css("span.Label", text: "A") + expect(page).to have_css("span.Label", text: "B") + expect(page).to have_css("span.Label", text: "C") + end + end + + context "when the section has no visible attributes" do + let(:user) { create(:user) } + + before { section.update_column(:attribute_order, []) } + + it "does not render" do + expect(component.render?).to be(false) + end + end + + context "with an untitled section" do + shared_let(:custom_field) do + create(:user_custom_field, :string, user_custom_field_section: section) + end + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "x")]) } + + before do + section.update_column(:name, nil) + section.update_column(:attribute_order, [custom_field.column_name]) + render_inline(component) + end + + it "renders the I18n fallback label" do + expect(page).to have_text(I18n.t("settings.user_custom_fields.label_untitled_section")) + end + end +end diff --git a/spec/components/users/profile/section_attributes_spec.rb b/spec/components/users/profile/section_attributes_spec.rb new file mode 100644 index 000000000000..042b6506d480 --- /dev/null +++ b/spec/components/users/profile/section_attributes_spec.rb @@ -0,0 +1,135 @@ +# 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 Users::Profile::SectionAttributes do + shared_let(:section) { create(:user_custom_field_section, name: "Profile info") } + shared_let(:custom_field) do + create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) + end + shared_let(:user) do + create(:user, + firstname: "Sarah", + lastname: "Chen", + login: "schen", + mail: "s.chen@example.com", + language: "en", + custom_values: [build(:custom_value, custom_field:, value: "Developer")]) + end + + # attribute_order interleaves built-ins and the custom field + before do + section.update_column(:attribute_order, ["firstname", custom_field.column_name, "mail", "language"]) + end + + subject(:attributes) { described_class.for(section:, user:, current_user:) } + + def labels = attributes.map(&:label) + def values = attributes.map(&:value) + + context "when current_user is the profile owner (self)" do + let(:current_user) { user } + + it "returns built-ins and custom fields in attribute_order" do + expect(labels).to eq([ + User.human_attribute_name("firstname"), + "Job title", + User.human_attribute_name("mail"), + User.human_attribute_name("language") + ]) + expect(values).to eq(["Sarah", "Developer", "s.chen@example.com", "English"]) + end + end + + context "when current_user can manage users" do + let(:current_user) { create(:user) } + + before { mock_permissions_for(current_user) { |mock| mock.allow_globally(:manage_user) } } + + it "shows the manage-gated built-ins and the email" do + expect(labels).to include(User.human_attribute_name("firstname"), + User.human_attribute_name("mail"), + User.human_attribute_name("language")) + end + end + + context "when current_user only has view_user_email" do + let(:current_user) { create(:user) } + + before { mock_permissions_for(current_user) { |mock| mock.allow_globally(:view_user_email) } } + + it "shows the email but not the manage-gated built-ins" do + expect(labels).to include(User.human_attribute_name("mail"), "Job title") + expect(labels).not_to include(User.human_attribute_name("firstname"), + User.human_attribute_name("language")) + end + end + + context "when current_user is a regular viewer" do + let(:current_user) { create(:user) } + + before { mock_permissions_for(current_user) {} } # rubocop:disable Lint/EmptyBlock -- viewer with no global permissions + + it "shows no gated built-ins (only the visible custom field)" do + expect(labels).to eq(["Job title"]) + end + end + + context "with a blank built-in value" do + let(:current_user) { user } + + before { user.update_column(:language, "") } + + it "skips the blank attribute" do + expect(labels).not_to include(User.human_attribute_name("language")) + end + end + + context "with a multi-value custom field" do + shared_let(:multi_cf) do + create(:user_custom_field, :multi_list, name: "Skills", user_custom_field_section: section) + end + let(:current_user) { user } + let(:user) do + create(:user, custom_values: multi_cf.possible_values.first(2).map do |v| + build(:custom_value, custom_field: multi_cf, value: v) + end) + end + + before { section.update_column(:attribute_order, [multi_cf.column_name]) } + + it "exposes the values as an array and flags multi_value?" do + attribute = attributes.first + expect(attribute.value).to eq(%w[A B]) + expect(attribute).to be_multi_value + end + end +end diff --git a/spec/factories/attribute_help_text_factory.rb b/spec/factories/attribute_help_text_factory.rb index c1244fda86c7..0a91bc0031f1 100644 --- a/spec/factories/attribute_help_text_factory.rb +++ b/spec/factories/attribute_help_text_factory.rb @@ -14,4 +14,11 @@ caption { "Some caption" } attribute_name { "status" } end + + factory :user_help_text, class: "AttributeHelpText::User" do + type { "AttributeHelpText::User" } + help_text { "Attribute help text" } + caption { "Some caption" } + attribute_name { "custom_field_1" } + end end diff --git a/spec/factories/custom_field_factory.rb b/spec/factories/custom_field_factory.rb index 3f2433d78e5a..ee595c6d36a0 100644 --- a/spec/factories/custom_field_factory.rb +++ b/spec/factories/custom_field_factory.rb @@ -243,7 +243,9 @@ end end - factory :user_custom_field, class: "UserCustomField" + factory :user_custom_field, class: "UserCustomField" do + user_custom_field_section + end factory :group_custom_field, class: "GroupCustomField" diff --git a/spec/factories/custom_field_section.rb b/spec/factories/custom_field_section.rb index bc71a5d1b769..7f6b47f20d08 100644 --- a/spec/factories/custom_field_section.rb +++ b/spec/factories/custom_field_section.rb @@ -36,5 +36,6 @@ updated_at { Time.zone.now } factory :project_custom_field_section, class: "ProjectCustomFieldSection" + factory :user_custom_field_section, class: "UserCustomFieldSection" end end diff --git a/spec/features/admin/custom_fields/projects/index_spec.rb b/spec/features/admin/custom_fields/projects/index_spec.rb index e184cc3647db..682b079a2949 100644 --- a/spec/features/admin/custom_fields/projects/index_spec.rb +++ b/spec/features/admin/custom_fields/projects/index_spec.rb @@ -358,6 +358,20 @@ def have_cf_admin_link(custom_field) custom_field_section_id: section_for_multi_select_fields.id )) end + + it "allows adding an attribute to a non-empty section via the section actions menu" do + # section_for_input_fields already has custom fields (non-empty), + # so the empty-state button is not shown here — the submenu must be. + within_project_custom_field_section_menu(section_for_input_fields) do + page.find_test_selector("new-project-custom-field-in-section-submenu").click + page.find_test_selector("new-project-custom-field-in-section-button-int").click + end + + expect(page).to have_current_path(new_admin_settings_project_custom_field_path( + field_format: "int", + custom_field_section_id: section_for_input_fields.id + )) + end end end diff --git a/spec/features/admin/custom_fields/shared_custom_field_expectations.rb b/spec/features/admin/custom_fields/shared_custom_field_expectations.rb index 385cf0ac137d..fb2623bd12d5 100644 --- a/spec/features/admin/custom_fields/shared_custom_field_expectations.rb +++ b/spec/features/admin/custom_fields/shared_custom_field_expectations.rb @@ -180,7 +180,7 @@ def expect_page_to_have(selectors) expect(page).to have_field(label_name) - if type == "Project" + if %w[Project User].include?(type) expect(page).to have_field(label_section) else expect(page).to have_no_label(label_section) diff --git a/spec/features/admin/custom_fields/users/boolean_spec.rb b/spec/features/admin/custom_fields/users/boolean_spec.rb index 0ead4a9ce1c2..086bb36998d2 100644 --- a/spec/features/admin/custom_fields/users/boolean_spec.rb +++ b/spec/features/admin/custom_fields/users/boolean_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user boolean custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Boolean" + it_behaves_like "expected fields for the User custom field's format", "Boolean" end diff --git a/spec/features/admin/custom_fields/users/create_in_section_spec.rb b/spec/features/admin/custom_fields/users/create_in_section_spec.rb new file mode 100644 index 000000000000..9c6501ba91ec --- /dev/null +++ b/spec/features/admin/custom_fields/users/create_in_section_spec.rb @@ -0,0 +1,116 @@ +# 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" +require_relative "shared_context" + +RSpec.describe "Create user custom fields in sections", :js do + include_context "with seeded user custom fields" + + let(:cf_index_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + + context "with insufficient permissions" do + it "is not accessible" do + login_as(non_admin) + cf_index_page.visit! + + expect(page).to have_text("You are not authorized to access this page.") + end + end + + context "with sufficient permissions" do + before do + login_as(admin) + cf_index_page.visit! + end + + context "when creating a Text user attribute" do + before do + cf_index_page.click_to_create_new_custom_field("Text") + end + + it "shows the correct breadcrumb" do + within ".PageHeader-breadcrumbs" do + expect(page).to have_link("Administration") + expect(page).to have_link("Users and permissions") + expect(page).to have_link("User attributes") + expect(page).to have_text("Text: New attribute") + end + end + + it "creates a user attribute assigned to a section" do + fill_in("custom_field_name", with: "Job title") + select(section_for_input_fields.name, from: "custom_field_custom_field_section_id") + check "Admin-only" + + click_on("Save") + + expect(page).to have_text("Successful creation") + + created = UserCustomField.find_by(name: "Job title") + expect(page).to have_current_path(edit_admin_settings_user_custom_field_path(created)) + expect(created.admin_only).to be(true) + expect(created.user_custom_field_section).to eq(section_for_input_fields) + end + + it "allows creating a user attribute with a prefilled section via URL param" do + visit new_admin_settings_user_custom_field_path(field_format: "text", + custom_field_section_id: section_for_select_fields.id) + + fill_in("custom_field_name", with: "Biography") + click_on("Save") + + expect(page).to have_text("Successful creation") + + created = UserCustomField.find_by(name: "Biography") + expect(created.user_custom_field_section).to eq(section_for_select_fields) + end + + it "prevents creating a user attribute with an empty name" do + click_on("Save") + + expect(page).to have_field("custom_field_name", with: "", validation_error: "Name can't be blank") + expect(page).to have_current_path(new_admin_settings_user_custom_field_path(field_format: "string")) + end + + context "without any existing sections" do + before do + UserCustomField.delete_all + UserCustomFieldSection.delete_all + cf_index_page.visit! + end + + it "prevents creating a new user attribute" do + cf_index_page.expect_no_add_user_attribute_submenu + end + end + end + end +end diff --git a/spec/features/admin/custom_fields/users/date_spec.rb b/spec/features/admin/custom_fields/users/date_spec.rb index b26fbdb90dc8..8fc0349e55f6 100644 --- a/spec/features/admin/custom_fields/users/date_spec.rb +++ b/spec/features/admin/custom_fields/users/date_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user date custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Date" + it_behaves_like "expected fields for the User custom field's format", "Date" end diff --git a/spec/features/admin/custom_fields/users/float_spec.rb b/spec/features/admin/custom_fields/users/float_spec.rb index d1e2f730a123..950873e78222 100644 --- a/spec/features/admin/custom_fields/users/float_spec.rb +++ b/spec/features/admin/custom_fields/users/float_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user float custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Float" + it_behaves_like "expected fields for the User custom field's format", "Float" end diff --git a/spec/features/admin/custom_fields/users/format_field_expectations.rb b/spec/features/admin/custom_fields/users/format_field_expectations.rb new file mode 100644 index 000000000000..32b22409e16d --- /dev/null +++ b/spec/features/admin/custom_fields/users/format_field_expectations.rb @@ -0,0 +1,41 @@ +# 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_relative "../shared_custom_field_expectations" + +RSpec.shared_examples_for "expected fields for the User custom field's format" do |format| + before do + create(:user_custom_field_section) + end + + include_examples "expected fields for the custom field's format", "Users", format do + let(:cf_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + end +end diff --git a/spec/features/admin/custom_fields/users/hierarchy_spec.rb b/spec/features/admin/custom_fields/users/hierarchy_spec.rb new file mode 100644 index 000000000000..cf09683d3ffe --- /dev/null +++ b/spec/features/admin/custom_fields/users/hierarchy_spec.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 +# 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" +require_relative "format_field_expectations" + +RSpec.describe "User hierarchy custom fields", :js do + context "with enterprise token", with_ee: [:custom_field_hierarchies] do + it_behaves_like "expected fields for the User custom field's format", "Hierarchy" + end +end diff --git a/spec/features/admin/custom_fields/users/index_spec.rb b/spec/features/admin/custom_fields/users/index_spec.rb new file mode 100644 index 000000000000..bf725e78ccb8 --- /dev/null +++ b/spec/features/admin/custom_fields/users/index_spec.rb @@ -0,0 +1,234 @@ +# 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" +require_relative "shared_context" + +RSpec.describe "List user custom fields", :js do + include_context "with seeded user custom fields" + + let(:cf_index_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + + context "with insufficient permissions" do + it "is not accessible" do + login_as(non_admin) + cf_index_page.visit! + + expect(page).to have_text("You are not authorized to access this page.") + end + end + + context "with sufficient permissions" do + before do + login_as(admin) + cf_index_page.visit! + end + + it "only allows user attribute creation when there is at least one section" do + cf_index_page.expect_add_user_attribute_submenu + + boolean_user_custom_field.destroy + string_user_custom_field.destroy + list_user_custom_field.destroy + section_for_input_fields.destroy + + cf_index_page.visit! + + # The (empty) select section is still there, so we can still add user attributes + cf_index_page.expect_add_user_attribute_submenu + + within_user_custom_field_section_menu(section_for_select_fields) do + accept_confirm do + click_on("Delete") + end + end + + # Now there are no sections left + cf_index_page.expect_no_add_user_attribute_submenu(close: false) + + cf_index_page.visit! + cf_index_page.expect_no_add_user_attribute_submenu(close: false) + end + + it "shows all sections in the correct order" do + containers = page.all(".op-user-custom-field-section-container") + + expect(containers[0].text).to include(section_for_input_fields.name) + expect(containers[1].text).to include(section_for_select_fields.name) + end + + it "shows all custom fields within their section" do + within_user_custom_field_section_container(section_for_input_fields) do + containers = page.all(".op-user-custom-field-container") + + expect(containers[0].text).to include(boolean_user_custom_field.name) + expect(containers[1].text).to include(string_user_custom_field.name) + end + + within_user_custom_field_section_container(section_for_select_fields) do + expect(page).to have_text(list_user_custom_field.name) + end + end + + it "allows to delete a section only if no user custom fields are assigned to it" do + within_user_custom_field_section_menu(section_for_select_fields) do + expect(page).to have_css("button[aria-disabled='true']", text: "Delete") + end + + list_user_custom_field.destroy + + cf_index_page.visit! + + within_user_custom_field_section_menu(section_for_select_fields) do + expect(page).to have_no_css("button[aria-disabled='true']", text: "Delete") + + accept_confirm do + click_on("Delete") + end + end + + expect(page) + .to have_no_css("[data-test-selector='user-custom-field-section-container-#{section_for_select_fields.id}']") + end + + it "allows to edit a section title" do + within_user_custom_field_section_menu(section_for_input_fields) do + click_on("Edit title") + end + + fill_in("user_custom_field_section_name", with: "Updated section name") + + click_on("Save") + + expect(page).to have_no_text(section_for_input_fields.name) + expect(page).to have_text("Updated section name") + end + + it "allows to create a new section" do + within "#settings-user-custom-fields-header-component" do + page.find_test_selector("user-attributes-add-menu-button").click + click_on("dialog-show-user-custom-field-section-dialog") + end + + fill_in("user_custom_field_section_name", with: "New section name") + + click_on("Save") + + expect(page).to have_text("New section name") + + containers = page.all(".op-user-custom-field-section-container") + + expect(containers[0].text).to include("New section name") + expect(containers[1].text).to include(section_for_input_fields.name) + expect(containers[2].text).to include(section_for_select_fields.name) + end + + it "allows to delete a custom field" do + within_user_custom_field_menu(boolean_user_custom_field) do + accept_confirm do + click_on("Delete") + end + end + + expect(page).to have_no_css("[data-test-selector='user-custom-field-container-#{boolean_user_custom_field.id}']") + end + + it "redirects to the custom field edit page via menu item" do + within_user_custom_field_menu(boolean_user_custom_field) do + click_on("Edit") + end + + expect(page).to have_current_path(edit_admin_settings_user_custom_field_path(boolean_user_custom_field)) + end + + it "redirects to the custom field edit page via click on the name" do + within_user_custom_field_container(boolean_user_custom_field) do + click_on(boolean_user_custom_field.name) + end + + expect(page).to have_current_path(edit_admin_settings_user_custom_field_path(boolean_user_custom_field)) + end + + it "redirects to the new custom field page via the empty section button" do + boolean_user_custom_field.destroy + string_user_custom_field.destroy + + cf_index_page.visit! + + within_user_custom_field_section_container(section_for_input_fields) do + page.find_test_selector("new-user-custom-field-in-section-button").click + page.find_test_selector("new-user-custom-field-in-section-button-int").click + end + + expect(page).to have_current_path(new_admin_settings_user_custom_field_path( + field_format: "int", + custom_field_section_id: section_for_input_fields.id + )) + end + + it "allows adding an attribute to a non-empty section via the section actions menu" do + # section_for_input_fields already has custom fields (non-empty), + # so the empty-state button is not shown here — the submenu must be. + within_user_custom_field_section_menu(section_for_input_fields) do + page.find_test_selector("new-user-custom-field-in-section-submenu").click + page.find_test_selector("new-user-custom-field-in-section-button-int").click + end + + expect(page).to have_current_path(new_admin_settings_user_custom_field_path( + field_format: "int", + custom_field_section_id: section_for_input_fields.id + )) + end + end + + # helper methods + + def within_user_custom_field_section_container(section, &) + within_test_selector("user-custom-field-section-container-#{section.id}", &) + end + + def within_user_custom_field_section_menu(section, &) + within_user_custom_field_section_container(section) do + page.find_test_selector("user-custom-field-section-action-menu").click + within("anchored-position", &) + end + end + + def within_user_custom_field_container(custom_field, &) + within_test_selector("user-custom-field-container-#{custom_field.id}", &) + end + + def within_user_custom_field_menu(custom_field, &) + within_user_custom_field_container(custom_field) do + page.find_test_selector("user-custom-field-action-menu").click + within("anchored-position", &) + end + end +end diff --git a/spec/features/admin/custom_fields/users/int_spec.rb b/spec/features/admin/custom_fields/users/int_spec.rb index 82d6b2586c65..7374c3dc478a 100644 --- a/spec/features/admin/custom_fields/users/int_spec.rb +++ b/spec/features/admin/custom_fields/users/int_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user int custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Integer" + it_behaves_like "expected fields for the User custom field's format", "Integer" end diff --git a/spec/features/admin/custom_fields/users/list_spec.rb b/spec/features/admin/custom_fields/users/list_spec.rb index ade785e1116c..1525efe2ead1 100644 --- a/spec/features/admin/custom_fields/users/list_spec.rb +++ b/spec/features/admin/custom_fields/users/list_spec.rb @@ -29,10 +29,69 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "users list custom fields", :js do - it_behaves_like "list custom fields", "Users" + let(:user) { create(:admin) } + let(:section) { create(:user_custom_field_section, name: "Test section") } + let(:cf_page) { Pages::Admin::Settings::UserCustomFields::Index.new } - it_behaves_like "expected fields for the custom field's format", "Users", "List" + current_user { user } + + before { section } + + it "has the options in the right order" do + cf_page.visit! + cf_page.click_to_create_new_custom_field "List" + + fill_in "custom_field_name", with: "Operating System" + select section.name, from: "custom_field_custom_field_section_id" + check "multi_value" + + click_on "Save" + + expect(page).to have_text("Successful creation") + expect(page).to have_field("multi_value", checked: true) + + click_link "Items" + wait_for_network_idle + + expect(page).to have_css(".custom-option-row", count: 1) + within all(".custom-option-row").last do + find(".custom-option-value input").set "Windows" + find(".custom-option-default-value input").set true + end + + retry_block do + page.find_test_selector("add-custom-option").click + expect(page).to have_css(".custom-option-row", count: 2) + end + + within all(".custom-option-row").last do + find(".custom-option-value input").set "Linux" + end + + retry_block do + page.find_test_selector("add-custom-option").click + expect(page).to have_css(".custom-option-row", count: 3) + end + + within all(".custom-option-row").last do + find(".custom-option-value input").set "Solaris" + click_on accessible_name: "Move to top" + end + + click_on "Save" + + expect(page).to have_css(".custom-option-row", count: 3) + expect(page).to have_field("custom_field_custom_options_attributes_0_value", with: "Solaris") + expect(page).to have_field("custom_field_custom_options_attributes_1_value", with: "Windows") + expect(page).to have_field("custom_field_custom_options_attributes_2_value", with: "Linux") + + expect(page).to have_field("custom_field_custom_options_attributes_0_default_value", checked: false) + expect(page).to have_field("custom_field_custom_options_attributes_1_default_value", checked: true) + expect(page).to have_field("custom_field_custom_options_attributes_2_default_value", checked: false) + end + + it_behaves_like "expected fields for the User custom field's format", "List" end diff --git a/spec/features/admin/custom_fields/users/long_text_spec.rb b/spec/features/admin/custom_fields/users/long_text_spec.rb index e7c6fb601640..13e37b125009 100644 --- a/spec/features/admin/custom_fields/users/long_text_spec.rb +++ b/spec/features/admin/custom_fields/users/long_text_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "users long text custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Long text" + it_behaves_like "expected fields for the User custom field's format", "Long text" end diff --git a/spec/features/admin/custom_fields/users/shared_context.rb b/spec/features/admin/custom_fields/users/shared_context.rb new file mode 100644 index 000000000000..09884f2f9722 --- /dev/null +++ b/spec/features/admin/custom_fields/users/shared_context.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +RSpec.shared_context "with seeded user custom fields" do + shared_let(:admin) { create(:admin) } + shared_let(:non_admin) { create(:user) } + + shared_let(:section_for_input_fields, refind: true) do + create(:user_custom_field_section, name: "Input fields") + end + shared_let(:section_for_select_fields, refind: true) do + create(:user_custom_field_section, name: "Select fields") + end + + shared_let(:boolean_user_custom_field, refind: true) do + create(:user_custom_field, name: "Boolean field", field_format: "bool", + user_custom_field_section: section_for_input_fields) + end + shared_let(:string_user_custom_field, refind: true) do + create(:user_custom_field, name: "String field", field_format: "string", + user_custom_field_section: section_for_input_fields) + end + shared_let(:list_user_custom_field, refind: true) do + create(:user_custom_field, name: "List field", field_format: "list", + possible_values: ["Option 1", "Option 2", "Option 3"], + user_custom_field_section: section_for_select_fields) + end +end diff --git a/spec/features/admin/custom_fields/users/text_spec.rb b/spec/features/admin/custom_fields/users/text_spec.rb index 926b822ca2b2..f2edade550f4 100644 --- a/spec/features/admin/custom_fields/users/text_spec.rb +++ b/spec/features/admin/custom_fields/users/text_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "users text custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Text" + it_behaves_like "expected fields for the User custom field's format", "Text" end diff --git a/spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb b/spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb new file mode 100644 index 000000000000..60fe39a7a5d7 --- /dev/null +++ b/spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb @@ -0,0 +1,126 @@ +# 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 "User custom field attribute help text", :js do + include Flash::Expectations + + shared_let(:admin) { create(:admin) } + shared_let(:section) { create(:user_custom_field_section) } + shared_let(:user_custom_field) do + create(:user_custom_field, name: "Job title", field_format: "text", user_custom_field_section: section) + end + + let(:editor) { Components::WysiwygEditor.new } + + before { login_as(admin) } + + describe "creating attribute help text" do + it "allows creating help text from the custom field edit page" do + visit edit_admin_settings_user_custom_field_path(user_custom_field) + + click_on "Help text" + + expect(page).to have_current_path( + attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + ) + + expect(page).to have_no_css("#attribute_help_text_attribute_name") + + fill_in "Caption", with: "Job title help" + editor.set_markdown("Enter your current job title") + + click_button "Save" + + expect(page).to have_current_path( + attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + ) + expect(page).to have_text("Successful update") + + help_text = AttributeHelpText::User.find_by(attribute_name: "custom_field_#{user_custom_field.id}") + expect(help_text).to be_present + expect(help_text.caption).to eq("Job title help") + expect(help_text.help_text).to include("Enter your current job title") + end + end + + describe "editing attribute help text" do + let!(:existing_help_text) do + create(:user_help_text, + attribute_name: "custom_field_#{user_custom_field.id}", + caption: "Original caption", + help_text: "Original help text") + end + + it "allows editing existing help text" do + visit attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + + expect(page).to have_field("Caption", with: "Original caption") + + fill_in "Caption", with: "Updated caption" + editor.clear + editor.set_markdown("Updated help text") + + click_button "Save" + + expect(page).to have_text("Successful update") + + existing_help_text.reload + expect(existing_help_text.caption).to eq("Updated caption") + expect(existing_help_text.help_text).to eq("Updated help text") + end + + it "shows validation errors when clearing help text" do + visit attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + + editor.clear + editor.set_markdown(" ") + click_button "Save" + expect(page).to have_text("Help text can't be blank") + end + end + + describe "tab navigation" do + it "navigates between Details and Help text tabs" do + visit edit_admin_settings_user_custom_field_path(user_custom_field) + + click_on "Help text" + expect(page).to have_current_path( + attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + ) + + click_on "Details" + expect(page).to have_current_path( + edit_admin_settings_user_custom_field_path(user_custom_field) + ) + end + end +end diff --git a/spec/features/auth/consent_auth_stage_spec.rb b/spec/features/auth/consent_auth_stage_spec.rb index c1ba31b9f0a6..8bf22545708c 100644 --- a/spec/features/auth/consent_auth_stage_spec.rb +++ b/spec/features/auth/consent_auth_stage_spec.rb @@ -31,6 +31,14 @@ require "spec_helper" RSpec.describe "Authentication Stages" do + # The new-user form renders the built-in attributes (login, mail, …) through + # the user attribute sections, so at least one section must list them. + shared_let(:default_section) do + create(:user_custom_field_section).tap do |s| + s.update_column(:attribute_order, UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + end + let(:language) { "en" } let(:user_password) { "bob" * 4 } let(:user) do diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 93e74f103500..d3cd3812b9b1 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -116,7 +116,7 @@ end end - string_project_custom_field.move_to_bottom + section_for_input_fields.reload.move_in_order(string_project_custom_field.column_name, :lowest) overview_page.visit_page diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb index 6042ee244056..420aa87c3d4c 100644 --- a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -401,7 +401,7 @@ expect(custom_fields[2].text).to include("Int field") end - boolean_project_custom_field.move_to_bottom + section_for_input_fields.reload.move_in_order(boolean_project_custom_field.column_name, :lowest) visit project_settings_project_custom_fields_path(project) diff --git a/spec/features/users/create_spec.rb b/spec/features/users/create_spec.rb index 7a546531d57e..0a53164a33bf 100644 --- a/spec/features/users/create_spec.rb +++ b/spec/features/users/create_spec.rb @@ -32,6 +32,11 @@ RSpec.describe "create users" do shared_let(:admin) { create(:admin) } + shared_let(:default_section) do + create(:user_custom_field_section).tap do |s| + s.update_column(:attribute_order, UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + end let(:current_user) { admin } let!(:auth_source) { create(:ldap_auth_source) } let(:new_user_page) { Pages::NewUser.new } diff --git a/spec/features/users/edit_users_spec.rb b/spec/features/users/edit_users_spec.rb index c139109a8234..23c65f6b8784 100644 --- a/spec/features/users/edit_users_spec.rb +++ b/spec/features/users/edit_users_spec.rb @@ -49,6 +49,28 @@ def user_password find "input#user_password" end + describe "custom fields" do + let!(:section) { create(:user_custom_field_section, name: "Professional info") } + let!(:string_cf) { create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) } + let!(:admin_only_cf) do + create(:user_custom_field, :string, name: "Internal code", admin_only: true, user_custom_field_section: section) + end + + before { visit edit_user_path(user) } + + it "saves a custom field value" do + within "fieldset", text: "Professional info".upcase do + fill_in "Job title", with: "Software Engineer" + fill_in "Internal code", with: "SE" + end + click_on "Save" + + expect(page).to have_text("Successful update") + expect(user.reload.custom_value_for(string_cf)&.value).to eq("Software Engineer") + expect(user.custom_value_for(admin_only_cf)&.value).to eq("SE") + end + end + context "with internal authentication" do before do visit edit_user_path(user) @@ -145,8 +167,9 @@ def have_visible_tab(label) end context "as admin" do + let(:another_admin) { create(:admin) } + it "can edit attributes of an admin user" do - another_admin = create(:admin) visit edit_user_path(another_admin) expect(page).to have_visible_tab("General") @@ -156,6 +179,11 @@ def have_visible_tab(label) context "as global user" do shared_let(:global_manage_user) { create(:user, global_permissions: %i[view_all_principals manage_user create_user]) } let(:current_user) { global_manage_user } + let!(:default_section) do + create(:user_custom_field_section).tap do |s| + s.update_column(:attribute_order, UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + end it "can too edit the user" do visit edit_user_path(user) diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index e46bb56ee476..b90a8f5a301d 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -69,4 +69,28 @@ expect(page).to have_no_text group.name end end + + describe "built-in and custom field attributes in the side panel" do + shared_let(:section) { create(:user_custom_field_section, name: "Public profile") } + shared_let(:custom_field) do + create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) + end + shared_let(:profile_user) do + create(:user, firstname: "Sarah", mail: "s.chen@example.com", + custom_values: [build(:custom_value, custom_field:, value: "Developer")]) + end + + before do + section.update_column(:attribute_order, ["firstname", "mail", custom_field.column_name]) + visit user_path(profile_user) + end + + it "shows built-in attributes alongside custom fields" do + expect(page).to have_text(User.human_attribute_name("firstname")) + expect(page).to have_text("Sarah") + expect(page).to have_text("s.chen@example.com") + expect(page).to have_text("Job title") + expect(page).to have_text("Developer") + end + end end diff --git a/spec/lib/open_project/custom_field_format_spec.rb b/spec/lib/open_project/custom_field_format_spec.rb index 074016678b10..18e4b50a631c 100644 --- a/spec/lib/open_project/custom_field_format_spec.rb +++ b/spec/lib/open_project/custom_field_format_spec.rb @@ -130,7 +130,7 @@ context "for a 'User' class" do it_behaves_like "custom field formats", "User", - %w[string text int float list date bool] + %w[string text int float list date bool hierarchy] end context "for a 'Group' class" do diff --git a/spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb b/spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb new file mode 100644 index 000000000000..dfde5de4e1e7 --- /dev/null +++ b/spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb @@ -0,0 +1,156 @@ +# 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" +require Rails.root.join("db/migrate/20260604120001_add_attribute_order_to_custom_field_sections") + +RSpec.describe AddAttributeOrderToCustomFieldSections, type: :model do + let(:conn) { ActiveRecord::Base.connection } + + # All setup uses raw SQL because attribute_order does not exist during up tests. + + def insert_section(type:, position:) + conn.select_value(<<~SQL.squish) + INSERT INTO custom_field_sections (type, name, position, display_representation, created_at, updated_at) + VALUES ('#{type}', '#{type} #{position}', #{position}, '{}', NOW(), NOW()) + RETURNING id + SQL + end + + def insert_cf(section_id:, position:) + conn.select_value(<<~SQL.squish) + INSERT INTO custom_fields + (type, name, field_format, custom_field_section_id, position_in_custom_field_section, + is_required, is_for_all, searchable, editable, admin_only, multi_value, + created_at, updated_at) + VALUES + ('UserCustomField', 'CF #{position}', 'string', #{section_id}, #{position}, + false, false, false, true, false, false, + NOW(), NOW()) + RETURNING id + SQL + end + + def attribute_order_of(section_id) + conn.select_value("SELECT attribute_order FROM custom_field_sections WHERE id = #{section_id}") + end + + # Before each example the schema is put in the pre-migration state: + # attribute_order removed, position_in_custom_field_section added. + before do + ActiveRecord::Migration.suppress_messages do + conn.add_column :custom_fields, :position_in_custom_field_section, :integer unless + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + conn.remove_column :custom_field_sections, :attribute_order if + conn.column_exists?(:custom_field_sections, :attribute_order) + end + end + + after do + ActiveRecord::Migration.suppress_messages do + unless conn.column_exists?(:custom_field_sections, :attribute_order) + conn.add_column :custom_field_sections, :attribute_order, :string, array: true, null: false, default: [] + end + conn.remove_column :custom_fields, :position_in_custom_field_section if + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + end + end + + describe "up migration" do + it "seeds attribute_order for UserCustomFieldSection from position_in_custom_field_section" do + section_id = insert_section(type: "UserCustomFieldSection", position: 1) + cf1_id = insert_cf(section_id:, position: 1) + cf2_id = insert_cf(section_id:, position: 2) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{section_id}") + expect(order).to include("cf_#{cf1_id}", "cf_#{cf2_id}") + expect(order.index("cf_#{cf1_id}")).to be < order.index("cf_#{cf2_id}") + end + + it "prepends built-in attributes to the first UserCustomFieldSection only" do + first_id = insert_section(type: "UserCustomFieldSection", position: 1) + second_id = insert_section(type: "UserCustomFieldSection", position: 2) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + first_order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{first_id}") + second_order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{second_id}") + + expect(first_order).to include("login", "firstname", "lastname", "mail", "language") + expect(second_order).not_to include("login") + end + + it "does not seed attribute_order for ProjectCustomFieldSection" do + section_id = insert_section(type: "ProjectCustomFieldSection", position: 1) + insert_cf(section_id:, position: 1) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{section_id}") + expect(order).to eq("{}") + end + + it "sets attribute_order to built-ins only for a UserCustomFieldSection with no custom fields" do + section_id = insert_section(type: "UserCustomFieldSection", position: 1) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{section_id}") + expect(order).to include("login") + end + end + + describe "down migration" do + before do + # Put schema back to post-migration-1 state for the down test. + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + end + + it "restores position_in_custom_field_section for user CFs from attribute_order" do + section = UserCustomFieldSection.create!(name: "S") + cf1 = create(:user_custom_field, user_custom_field_section: section) + cf2 = create(:user_custom_field, user_custom_field_section: section) + section.update_column(:attribute_order, [cf2.column_name, cf1.column_name]) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(conn.select_value("SELECT position_in_custom_field_section FROM custom_fields WHERE id = #{cf2.id}").to_i).to eq(1) + expect(conn.select_value("SELECT position_in_custom_field_section FROM custom_fields WHERE id = #{cf1.id}").to_i).to eq(2) + end + + it "removes the attribute_order column" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(conn.column_exists?(:custom_field_sections, :attribute_order)).to be false + end + end +end diff --git a/spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.rb b/spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.rb new file mode 100644 index 000000000000..75a72adda538 --- /dev/null +++ b/spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.rb @@ -0,0 +1,128 @@ +# 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" +require Rails.root.join("db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields") + +RSpec.describe RemovePositionInCustomFieldSectionFromCustomFields, type: :model do + let(:conn) { ActiveRecord::Base.connection } + + def position_of(cf) + conn.select_value("SELECT position_in_custom_field_section FROM custom_fields WHERE id = #{cf.id}")&.to_i + end + + describe "up migration" do + let(:section) { create(:project_custom_field_section) } + let!(:cf1) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf2) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf3) { create(:project_custom_field, project_custom_field_section: section) } + + before do + # Put schema in pre-migration state: add the column and set position values. + ActiveRecord::Migration.suppress_messages do + conn.add_column :custom_fields, :position_in_custom_field_section, :integer + end + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 1 WHERE id = #{cf1.id}") + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 2 WHERE id = #{cf2.id}") + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 3 WHERE id = #{cf3.id}") + section.update_column(:attribute_order, []) + end + + after do + ActiveRecord::Migration.suppress_messages do + conn.remove_column :custom_fields, :position_in_custom_field_section if + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + end + end + + it "seeds attribute_order from position_in_custom_field_section" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(section.reload.attribute_order).to eq([cf1.column_name, cf2.column_name, cf3.column_name]) + end + + it "removes the position_in_custom_field_section column" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(conn.column_exists?(:custom_fields, :position_in_custom_field_section)).to be false + end + + it "does not seed attribute_order for UserCustomFieldSection" do + user_section = create(:user_custom_field_section) + cf_user = create(:user_custom_field, user_custom_field_section: user_section) + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 1 WHERE id = #{cf_user.id}") + user_section.update_column(:attribute_order, []) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(user_section.reload.attribute_order).to be_empty + end + end + + describe "down migration" do + let(:section) { create(:project_custom_field_section) } + let!(:cf1) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf2) { create(:project_custom_field, project_custom_field_section: section) } + + before do + section.update_column(:attribute_order, [cf2.column_name, cf1.column_name]) + end + + after do + ActiveRecord::Migration.suppress_messages do + conn.remove_column :custom_fields, :position_in_custom_field_section if + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + end + end + + it "adds position_in_custom_field_section back" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(conn.column_exists?(:custom_fields, :position_in_custom_field_section)).to be true + end + + it "restores positions from attribute_order" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(position_of(cf2)).to eq(1) + expect(position_of(cf1)).to eq(2) + end + + it "does not touch user section custom fields" do + user_section = create(:user_custom_field_section) + cf_user = create(:user_custom_field, user_custom_field_section: user_section) + user_section.update_column(:attribute_order, [cf_user.column_name]) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(position_of(cf_user)).to be_nil + end + end +end diff --git a/spec/models/project/pdf_export/project_initiation_spec.rb b/spec/models/project/pdf_export/project_initiation_spec.rb index 24175cb77a1b..391a269b57ef 100644 --- a/spec/models/project/pdf_export/project_initiation_spec.rb +++ b/spec/models/project/pdf_export/project_initiation_spec.rb @@ -127,19 +127,19 @@ "The description of the project", "Section A", - link_cf.name, "https://www.example.com", + bool_cf.name, "Yes", text_cf.name, "Some ", "long", " text", string_cf.name, "Some small text", - bool_cf.name, "Yes", + link_cf.name, "https://www.example.com", unset_string_cf.name, "–", "Section B", version_cf.name, system_version, "#{version_cf.name} comment", "Comment visible to members", user_cf.name, "Other User", - date_cf.name, format_date(Time.zone.today), - float_cf.name, "4.5", int_cf.name, "5", + float_cf.name, "4.5", + date_cf.name, format_date(Time.zone.today), "1/1", heading, project.name ].join(" ") @@ -160,21 +160,21 @@ "The description of the project", "Section A", + bool_cf.name, "Yes", + text_cf.name, "Some ", "long", " text", + string_cf.name, "Some small text", hidden_cf.name, "hidden", "#{hidden_cf.name} comment", "Comment visible to admins", link_cf.name, "https://www.example.com", - text_cf.name, "Some ", "long", " text", - string_cf.name, "Some small text", - bool_cf.name, "Yes", unset_string_cf.name, "–", "Section B", version_cf.name, system_version, "#{version_cf.name} comment", "Comment visible to members", user_cf.name, "Other User", - date_cf.name, format_date(Time.zone.today), - float_cf.name, "4.5", int_cf.name, "5", + float_cf.name, "4.5", + date_cf.name, format_date(Time.zone.today), "1/1", heading, project.name ].join(" ") diff --git a/spec/models/project_custom_field_section_spec.rb b/spec/models/project_custom_field_section_spec.rb new file mode 100644 index 000000000000..f7a79390a942 --- /dev/null +++ b/spec/models/project_custom_field_section_spec.rb @@ -0,0 +1,161 @@ +# 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 ProjectCustomFieldSection do + describe ".with_available_fields_for" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + # Admin sees all non-admin-only CFs regardless of project membership. + let(:admin) { create(:admin) } + + let!(:section_a) { create(:project_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:project_custom_field_section, name: "Section B", position: 2) } + # Creation order determines initial attribute_order within each section. + let!(:cf_a1) do + create(:string_project_custom_field, name: "A1", project_custom_field_section: section_a, projects: [project]) + end + let!(:cf_a2) do + create(:string_project_custom_field, name: "A2", project_custom_field_section: section_a, projects: [project]) + end + let!(:cf_b1) do + create(:string_project_custom_field, name: "B1", project_custom_field_section: section_b, projects: [project]) + end + + before { allow(User).to receive(:current).and_return(admin) } + + it "returns one [section, cfs] pair per section that has available CFs" do + result = described_class.with_available_fields_for(project) + expect(result.size).to eq(2) + end + + it "orders pairs by section position" do + result = described_class.with_available_fields_for(project) + expect(result.map(&:first)).to eq([section_a, section_b]) + end + + it "orders CFs within a section by attribute_order" do + result = described_class.with_available_fields_for(project) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a1, cf_a2]) + end + + it "respects a manually reordered attribute_order" do + section_a.update_column(:attribute_order, [cf_a2.column_name, cf_a1.column_name]) + result = described_class.with_available_fields_for(project) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a2, cf_a1]) + end + + it "excludes CFs not mapped to the project" do + unmapped = create(:string_project_custom_field, name: "Unmapped", + project_custom_field_section: section_a, + projects: [other_project]) + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).not_to include(unmapped) + end + + it "excludes sections that have no CFs available for the project" do + empty_section = create(:project_custom_field_section, name: "Empty", position: 3) + create(:string_project_custom_field, name: "Other", + project_custom_field_section: empty_section, + projects: [other_project]) + result = described_class.with_available_fields_for(project) + expect(result.map(&:first)).not_to include(empty_section) + end + + it "returns an empty array when the project has no available CFs" do + expect(described_class.with_available_fields_for(other_project)).to eq([]) + end + + context "with admin_only CFs" do + let!(:admin_cf) do + create(:string_project_custom_field, name: "Secret", + project_custom_field_section: section_a, + projects: [project], + admin_only: true) + end + + context "when viewer is not an admin" do + let(:non_admin) do + create(:user, member_with_permissions: { project => [:select_project_custom_fields] }) + end + + before { allow(User).to receive(:current).and_return(non_admin) } + + it "excludes admin_only CFs" do + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).not_to include(admin_cf) + end + + it "still returns non-admin CFs in the same section" do + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).to include(cf_a1, cf_a2) + end + end + + context "when viewer is an admin" do + it "includes admin_only CFs" do + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).to include(admin_cf) + end + end + end + end + + describe ".grouped_in_order" do + let!(:section_a) { create(:project_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:project_custom_field_section, name: "Section B", position: 2) } + let!(:cf_a1) { create(:string_project_custom_field, name: "A1", project_custom_field_section: section_a) } + let!(:cf_a2) { create(:string_project_custom_field, name: "A2", project_custom_field_section: section_a) } + let!(:cf_b1) { create(:string_project_custom_field, name: "B1", project_custom_field_section: section_b) } + + it "orders sections by position and fields by attribute_order" do + result = described_class.grouped_in_order(ProjectCustomField.where(id: [cf_a1, cf_a2, cf_b1])) + expect(result.map(&:first)).to eq([section_a, section_b]) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a1, cf_a2]) + end + + it "respects a manually reordered attribute_order" do + section_a.update_column(:attribute_order, [cf_a2.column_name, cf_a1.column_name]) + result = described_class.grouped_in_order(ProjectCustomField.where(id: [cf_a1, cf_a2])) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a2, cf_a1]) + end + + it "restricts to the given custom fields" do + result = described_class.grouped_in_order(ProjectCustomField.where(id: [cf_a1])) + expect(result.map(&:first)).to eq([section_a]) + expect(result.flat_map(&:last)).to eq([cf_a1]) + end + + it "returns an empty array for an empty relation" do + expect(described_class.grouped_in_order(ProjectCustomField.none)).to eq([]) + end + end +end diff --git a/spec/models/project_custom_field_spec.rb b/spec/models/project_custom_field_spec.rb index 314d25e6add8..5b08e8bb27c3 100644 --- a/spec/models/project_custom_field_spec.rb +++ b/spec/models/project_custom_field_spec.rb @@ -104,6 +104,15 @@ end end + context "when deleting a project custom field via section cascade" do + let(:section) { create(:project_custom_field_section) } + let!(:cf) { create(:project_custom_field, project_custom_field_section: section) } + + it "does not raise an error when the section is destroyed with its fields" do + expect { section.destroy! }.not_to raise_error + end + end + context "when deleting a project custom field" do let!(:project_custom_field) { create(:string_project_custom_field) } let!(:project) do @@ -122,6 +131,35 @@ end end + describe "attribute_order integration" do + let(:section) { create(:project_custom_field_section) } + + it "appends itself to the section's attribute_order on creation" do + cf = create(:project_custom_field, project_custom_field_section: section) + expect(section.reload.attribute_order).to include(cf.column_name) + end + + it "orders fields by creation order when multiple are created" do + cf1 = create(:project_custom_field, project_custom_field_section: section) + cf2 = create(:project_custom_field, project_custom_field_section: section) + expect(section.reload.attribute_order).to eq([cf1.column_name, cf2.column_name]) + end + + it "removes itself from the section's attribute_order on destruction" do + cf = create(:project_custom_field, project_custom_field_section: section) + cf.destroy + expect(section.reload.attribute_order).not_to include(cf.column_name) + end + + it "scopes positions per section (each section is independent)" do + other_section = create(:project_custom_field_section) + cf1 = create(:project_custom_field, project_custom_field_section: section) + cf2 = create(:project_custom_field, project_custom_field_section: other_section) + expect(section.reload.attribute_order).to eq([cf1.column_name]) + expect(other_section.reload.attribute_order).to eq([cf2.column_name]) + end + end + describe ".visible" do shared_let(:invisible_cf) { create(:string_project_custom_field, admin_only: true) } shared_let(:other_cf) { create(:string_project_custom_field) } diff --git a/spec/models/user_custom_field_section_spec.rb b/spec/models/user_custom_field_section_spec.rb new file mode 100644 index 000000000000..f1edab2356fb --- /dev/null +++ b/spec/models/user_custom_field_section_spec.rb @@ -0,0 +1,295 @@ +# 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 UserCustomFieldSection do + subject(:section) { create(:user_custom_field_section) } + + describe "constants" do + it "defines the five built-in attribute keys" do + expect(described_class::BUILT_IN_ATTRIBUTES).to eq(%w[login firstname lastname mail language]) + end + end + + describe "#untitled?" do + it { expect(described_class.new(name: nil)).to be_untitled } + it { expect(described_class.new(name: "")).to be_untitled } + it { expect(described_class.new(name: "My section")).not_to be_untitled } + end + + describe "#add_to_order" do + it "appends to the end when no position given" do + section.add_to_order("cf_1") + section.add_to_order("cf_2") + expect(section.reload.attribute_order).to eq(%w[cf_1 cf_2]) + end + + it "inserts at the given 1-indexed position" do + section.update_column(:attribute_order, %w[cf_1 cf_3]) + section.add_to_order("cf_2", position: 2) + expect(section.reload.attribute_order).to eq(%w[cf_1 cf_2 cf_3]) + end + + it "is idempotent — removes prior occurrence before inserting" do + section.update_column(:attribute_order, %w[cf_1 cf_2 cf_3]) + section.add_to_order("cf_2", position: 1) + expect(section.reload.attribute_order).to eq(%w[cf_2 cf_1 cf_3]) + end + end + + describe "#remove_from_order" do + it "removes the key" do + section.update_column(:attribute_order, %w[cf_1 cf_2 cf_3]) + section.remove_from_order("cf_2") + expect(section.reload.attribute_order).to eq(%w[cf_1 cf_3]) + end + + it "is a no-op when key is absent" do + section.update_column(:attribute_order, %w[cf_1]) + expect { section.remove_from_order("cf_99") }.not_to change { section.reload.attribute_order } + end + end + + describe "#move_in_order" do + before { section.update_column(:attribute_order, %w[a b c d]) } + + it "moves to highest (index 0)" do + section.move_in_order("c", :highest) + expect(section.reload.attribute_order).to eq(%w[c a b d]) + end + + it "moves to lowest (last index)" do + section.move_in_order("b", :lowest) + expect(section.reload.attribute_order).to eq(%w[a c d b]) + end + + it "moves higher (one step earlier)" do + section.move_in_order("c", :higher) + expect(section.reload.attribute_order).to eq(%w[a c b d]) + end + + it "moves lower (one step later)" do + section.move_in_order("b", :lower) + expect(section.reload.attribute_order).to eq(%w[a c b d]) + end + + it "clamps: moving highest item higher stays at index 0" do + section.move_in_order("a", :higher) + expect(section.reload.attribute_order).to eq(%w[a b c d]) + end + + it "clamps: moving lowest item lower stays at the end" do + section.move_in_order("d", :lower) + expect(section.reload.attribute_order).to eq(%w[a b c d]) + end + + it "is a no-op when key is absent" do + expect { section.move_in_order("z", :higher) }.not_to change { section.reload.attribute_order } + end + end + + describe "#custom_fields_by_key" do + let!(:cf1) { create(:user_custom_field, user_custom_field_section: section) } + let!(:cf2) { create(:user_custom_field, user_custom_field_section: section) } + + it "returns a hash keyed by cf_" do + result = section.reload.custom_fields_by_key + expect(result[cf1.column_name]).to eq(cf1) + expect(result[cf2.column_name]).to eq(cf2) + end + end + + describe ".with_custom_fields" do + let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } + let!(:field_a1) { create(:user_custom_field, user_custom_field_section: section_a) } + let!(:field_a2) { create(:user_custom_field, user_custom_field_section: section_a) } + let!(:field_b1) { create(:user_custom_field, user_custom_field_section: section_b) } + + it "returns only sections containing at least one matching field" do + result = described_class.with_custom_fields([field_a1.id]) + expect(result).to contain_exactly(section_a) + end + + it "excludes sections with no matching fields" do + result = described_class.with_custom_fields([field_a1.id]) + expect(result).not_to include(section_b) + end + + it "orders sections by position" do + result = described_class.with_custom_fields([field_a1.id, field_b1.id]) + expect(result).to eq([section_a, section_b]) + end + + it "preloads the matching custom fields on each section" do + result = described_class.with_custom_fields([field_a1.id, field_a2.id]) + expect(result.first.custom_fields).to contain_exactly(field_a1, field_a2) + end + end + + describe ".with_filled_fields_for" do + let(:viewer) { create(:user) } + let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } + # Creation order determines initial attribute_order within each section. + let!(:cf_a1) { create(:user_custom_field, :string, name: "A1", user_custom_field_section: section_a) } + let!(:cf_a2) { create(:user_custom_field, :string, name: "A2", user_custom_field_section: section_a) } + let!(:cf_b1) { create(:user_custom_field, :string, name: "B1", user_custom_field_section: section_b) } + + before { allow(User).to receive(:current).and_return(viewer) } + + context "when user has no custom field values" do + let(:profile) { create(:user) } + + it "returns an empty array" do + expect(described_class.with_filled_fields_for(profile)).to eq([]) + end + end + + context "when all values are blank" do + let(:profile) { create(:user, custom_values: [build(:custom_value, custom_field: cf_a1, value: "")]) } + + it "returns an empty array" do + expect(described_class.with_filled_fields_for(profile)).to eq([]) + end + end + + context "when user has filled values in multiple sections" do + let(:profile) do + create(:user, custom_values: [ + build(:custom_value, custom_field: cf_a1, value: "hello"), + build(:custom_value, custom_field: cf_b1, value: "world") + ]) + end + + it "returns one [section, cfs] pair per matching section" do + result = described_class.with_filled_fields_for(profile) + expect(result.size).to eq(2) + end + + it "orders pairs by section position" do + result = described_class.with_filled_fields_for(profile) + expect(result.map(&:first)).to eq([section_a, section_b]) + end + + it "includes only the CFs that have a filled value, not all CFs in the section" do + result = described_class.with_filled_fields_for(profile) + section_a_cfs = result.find { |s, _| s == section_a }.last + expect(section_a_cfs).to eq([cf_a1]) + expect(section_a_cfs).not_to include(cf_a2) + end + end + + context "when a section has multiple filled CFs" do + let(:profile) do + create(:user, custom_values: [ + build(:custom_value, custom_field: cf_a1, value: "first"), + build(:custom_value, custom_field: cf_a2, value: "second") + ]) + end + + it "orders CFs within a section by attribute_order" do + result = described_class.with_filled_fields_for(profile) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a1, cf_a2]) + end + + it "respects a manually reordered attribute_order" do + section_a.update_column(:attribute_order, [cf_a2.column_name, cf_a1.column_name]) + result = described_class.with_filled_fields_for(profile) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a2, cf_a1]) + end + end + + context "with admin_only CFs" do + let!(:admin_cf) do + create(:user_custom_field, :string, name: "Secret", user_custom_field_section: section_a, admin_only: true) + end + let(:profile) { create(:user, custom_values: [build(:custom_value, custom_field: admin_cf, value: "secret")]) } + + context "when viewer is not an admin" do + it "excludes admin_only CFs from results" do + result = described_class.with_filled_fields_for(profile) + expect(result.flat_map(&:last)).not_to include(admin_cf) + end + + it "excludes the section entirely when all its filled CFs are admin_only" do + expect(described_class.with_filled_fields_for(profile)).to eq([]) + end + end + + context "when viewer is an admin" do + let(:viewer) { create(:admin) } + + it "includes admin_only CFs" do + result = described_class.with_filled_fields_for(profile) + expect(result.flat_map(&:last)).to include(admin_cf) + end + end + end + + context "with visible_on_user_card" do + let!(:card_cf) do + create(:user_custom_field, :string, name: "On card", + user_custom_field_section: section_a, visible_on_user_card: true) + end + let!(:no_card_cf) do + create(:user_custom_field, :string, name: "Off card", + user_custom_field_section: section_a, visible_on_user_card: false) + end + let(:profile) do + create(:user, custom_values: [ + build(:custom_value, custom_field: card_cf, value: "card value"), + build(:custom_value, custom_field: no_card_cf, value: "off-card value") + ]) + end + + it "returns all visible CFs when visible_on_user_card is not specified" do + result = described_class.with_filled_fields_for(profile) + all_cfs = result.flat_map(&:last) + expect(all_cfs).to include(card_cf, no_card_cf) + end + + it "restricts to card-flagged CFs when visible_on_user_card: true" do + result = described_class.with_filled_fields_for(profile, visible_on_user_card: true) + all_cfs = result.flat_map(&:last) + expect(all_cfs).to include(card_cf) + expect(all_cfs).not_to include(no_card_cf) + end + + it "excludes the section when its only filled card-visible CF is filtered out" do + profile_no_card = create(:user, custom_values: [ + build(:custom_value, custom_field: no_card_cf, value: "only off-card") + ]) + expect(described_class.with_filled_fields_for(profile_no_card, visible_on_user_card: true)).to eq([]) + end + end + end +end diff --git a/spec/models/user_custom_field_spec.rb b/spec/models/user_custom_field_spec.rb new file mode 100644 index 000000000000..b5ab70501f0a --- /dev/null +++ b/spec/models/user_custom_field_spec.rb @@ -0,0 +1,127 @@ +# 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 UserCustomField do + describe "validations" do + it "is invalid without a section" do + cf = build(:user_custom_field, custom_field_section_id: nil) + expect(cf).not_to be_valid + expect(cf.errors[:custom_field_section_id]).to be_present + end + + it "is valid with a section" do + expect(create(:user_custom_field)).to be_valid + end + end + + describe "associations" do + it "belongs to a UserCustomFieldSection" do + section = create(:user_custom_field_section) + cf = create(:user_custom_field, user_custom_field_section: section) + expect(cf.user_custom_field_section).to eq(section) + end + end + + describe "#type_name" do + it "returns :label_user_plural" do + expect(described_class.new.type_name).to eq(:label_user_plural) + end + end + + describe "defaults" do + it "defaults visible_on_user_card to false" do + cf = create(:user_custom_field) + expect(cf.visible_on_user_card).to be(false) + end + end + + describe "attribute_order integration" do + let(:section) { create(:user_custom_field_section) } + + it "appends itself to the section's attribute_order on creation" do + cf = create(:user_custom_field, user_custom_field_section: section) + expect(section.reload.attribute_order).to include(cf.column_name) + end + + it "orders fields by creation order when multiple are created" do + cf1 = create(:user_custom_field, user_custom_field_section: section) + cf2 = create(:user_custom_field, user_custom_field_section: section) + expect(section.reload.attribute_order).to eq([cf1.column_name, cf2.column_name]) + end + + it "removes itself from the section's attribute_order on destruction" do + cf = create(:user_custom_field, user_custom_field_section: section) + cf.destroy + expect(section.reload.attribute_order).not_to include(cf.column_name) + end + + it "scopes positions per section (each section is independent)" do + other_section = create(:user_custom_field_section) + cf1 = create(:user_custom_field, user_custom_field_section: section) + cf2 = create(:user_custom_field, user_custom_field_section: other_section) + expect(section.reload.attribute_order).to eq([cf1.column_name]) + expect(other_section.reload.attribute_order).to eq([cf2.column_name]) + end + end + + describe ".visible" do + let!(:admin_only_cf) { create(:user_custom_field, admin_only: true) } + let!(:public_cf) { create(:user_custom_field, admin_only: false) } + + context "for an admin" do + it "returns all custom fields" do + expect(described_class.visible(build(:admin))).to include(admin_only_cf, public_cf) + end + end + + context "for a non-admin" do + it "returns only non-admin_only custom fields" do + expect(described_class.visible(build(:user))).to include(public_cf) + expect(described_class.visible(build(:user))).not_to include(admin_only_cf) + end + end + end + + describe "section deletion restriction" do + let(:section) { create(:user_custom_field_section) } + let!(:cf) { create(:user_custom_field, user_custom_field_section: section) } + + it "raises when attempting to destroy a section that still holds custom fields" do + expect { section.destroy! }.to raise_error(ActiveRecord::DeleteRestrictionError) + end + + it "removes itself from the section's attribute_order when destroyed individually" do + cf.destroy! + expect(section.reload.attribute_order).not_to include(cf.column_name) + end + end +end diff --git a/spec/services/custom_fields/drop_service_spec.rb b/spec/services/custom_fields/drop_service_spec.rb new file mode 100644 index 000000000000..d9f74e9e64d9 --- /dev/null +++ b/spec/services/custom_fields/drop_service_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe CustomFields::DropService do + let(:admin) { build(:admin) } + + shared_examples "drops fields within and across sections" do |cf_factory:, section_factory:, section_assoc:| + let(:section_a) { create(section_factory, position: 1) } + let(:section_b) { create(section_factory, position: 2) } + let!(:cf1) { create(cf_factory, section_assoc => section_a) } + let!(:cf2) { create(cf_factory, section_assoc => section_a) } + + subject(:service) { described_class.new(user: admin, custom_field: cf2) } + + describe "reordering within the same section" do + it "moves to the target position" do + result = service.call(target_id: section_a.id, position: 1) + expect(result).to be_success + expect(section_a.reload.attribute_order).to eq([cf2.column_name, cf1.column_name]) + end + + it "reports section_changed as false" do + result = service.call(target_id: section_a.id, position: 1) + expect(result.result[:section_changed]).to be(false) + end + end + + describe "moving to a different section" do + it "removes the field from the source section" do + service.call(target_id: section_b.id, position: 1) + expect(section_a.reload.attribute_order).not_to include(cf2.column_name) + end + + it "inserts the field at the target position in the new section" do + service.call(target_id: section_b.id, position: 1) + expect(section_b.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "updates the custom field's section" do + service.call(target_id: section_b.id, position: 1) + expect(cf2.reload.custom_field_section_id).to eq(section_b.id) + end + + it "reports section_changed as true with both sections" do + result = service.call(target_id: section_b.id, position: 1) + expect(result.result[:section_changed]).to be(true) + expect(result.result[:current_section]).to eq(section_b) + expect(result.result[:old_section]).to eq(section_a) + end + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + expect(service.call(target_id: section_a.id, position: 1)).not_to be_success + end + end + end + + describe "with UserCustomField" do + include_examples "drops fields within and across sections", + cf_factory: :user_custom_field, + section_factory: :user_custom_field_section, + section_assoc: :user_custom_field_section + end + + describe "with ProjectCustomField" do + include_examples "drops fields within and across sections", + cf_factory: :project_custom_field, + section_factory: :project_custom_field_section, + section_assoc: :project_custom_field_section + end +end diff --git a/spec/services/custom_fields/move_service_spec.rb b/spec/services/custom_fields/move_service_spec.rb new file mode 100644 index 000000000000..c6b75aeebedf --- /dev/null +++ b/spec/services/custom_fields/move_service_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 CustomFields::MoveService do + let(:admin) { build(:admin) } + + shared_examples "moves fields within a section" do |cf_factory:, section_factory:, section_assoc:| + let(:section) { create(section_factory) } + let!(:cf1) { create(cf_factory, section_assoc => section) } + let!(:cf2) { create(cf_factory, section_assoc => section) } + let!(:cf3) { create(cf_factory, section_assoc => section) } + + subject(:service) { described_class.new(user: admin, custom_field: cf2) } + + it "moves higher" do + result = service.call(move_to: "higher") + expect(result).to be_success + order = section.reload.attribute_order + expect(order.index(cf2.column_name)).to be < order.index(cf1.column_name) + end + + it "moves lower" do + result = service.call(move_to: "lower") + expect(result).to be_success + order = section.reload.attribute_order + expect(order.index(cf2.column_name)).to be > order.index(cf1.column_name) + end + + it "moves to the top" do + service.call(move_to: "highest") + expect(section.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "moves to the bottom" do + service.call(move_to: "lowest") + expect(section.reload.attribute_order.last).to eq(cf2.column_name) + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + expect(service.call(move_to: "higher")).not_to be_success + end + end + end + + describe "with UserCustomField" do + include_examples "moves fields within a section", + cf_factory: :user_custom_field, + section_factory: :user_custom_field_section, + section_assoc: :user_custom_field_section + end + + describe "with ProjectCustomField" do + include_examples "moves fields within a section", + cf_factory: :project_custom_field, + section_factory: :project_custom_field_section, + section_assoc: :project_custom_field_section + end +end diff --git a/spec/services/user_custom_field_sections/create_service_spec.rb b/spec/services/user_custom_field_sections/create_service_spec.rb new file mode 100644 index 000000000000..ad358cb5c381 --- /dev/null +++ b/spec/services/user_custom_field_sections/create_service_spec.rb @@ -0,0 +1,72 @@ +# 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 UserCustomFieldSections::CreateService do + let(:admin) { build_stubbed(:admin) } + let(:user) { build_stubbed(:user) } + + subject(:call) { described_class.new(user: current_user).call(name: "New section") } + + context "when called by an admin" do + let(:current_user) { admin } + + it "creates the section successfully" do + expect(call).to be_success + expect(call.result).to be_a(UserCustomFieldSection) + expect(call.result.name).to eq("New section") + end + + it "persists the section to the database" do + expect { call }.to change(UserCustomFieldSection, :count).by(1) + end + end + + context "when called by a non-admin" do + let(:current_user) { user } + + it "fails authorization" do + expect(call).not_to be_success + expect(call.result).not_to be_persisted + end + end + + context "with invalid attributes" do + let(:current_user) { admin } + + subject(:call) { described_class.new(user: admin).call(name: "") } + + it "fails validation" do + expect(call).not_to be_success + expect(call.result.errors[:name]).to be_present + end + end +end diff --git a/spec/services/user_custom_field_sections/delete_service_spec.rb b/spec/services/user_custom_field_sections/delete_service_spec.rb new file mode 100644 index 000000000000..7eac6e778c31 --- /dev/null +++ b/spec/services/user_custom_field_sections/delete_service_spec.rb @@ -0,0 +1,71 @@ +# 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 UserCustomFieldSections::DeleteService do + let(:admin) { build_stubbed(:admin) } + let(:user) { build_stubbed(:user) } + let(:section) { create(:user_custom_field_section) } + + context "when called by an admin on an empty section" do + subject(:call) { described_class.new(user: admin, model: section).call } + + it "deletes the section" do + expect(call).to be_success + expect(UserCustomFieldSection.exists?(section.id)).to be(false) + end + end + + context "when called by a non-admin" do + subject(:call) { described_class.new(user: user, model: section).call } + + it "fails authorization" do + expect(call).not_to be_success + expect(UserCustomFieldSection.exists?(section.id)).to be(true) + end + end + + context "when the section has custom fields" do + before { create(:user_custom_field, user_custom_field_section: section) } + + subject(:call) { described_class.new(user: admin, model: section).call } + + it "fails with an in_use error" do + expect(call).not_to be_success + expect(call.result.errors).to be_of_kind(:base, :in_use) + end + + it "does not delete the section" do + call + expect(UserCustomFieldSection.exists?(section.id)).to be(true) + end + end +end diff --git a/spec/services/user_custom_field_sections/update_service_spec.rb b/spec/services/user_custom_field_sections/update_service_spec.rb new file mode 100644 index 000000000000..fd90568d3a91 --- /dev/null +++ b/spec/services/user_custom_field_sections/update_service_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe UserCustomFieldSections::UpdateService do + let(:admin) { build_stubbed(:admin) } + let(:user) { build_stubbed(:user) } + let(:section) { create(:user_custom_field_section, name: "Original name") } + + context "when called by an admin" do + subject(:call) { described_class.new(user: admin, model: section).call(name: "Updated name") } + + it "updates the section successfully" do + expect(call).to be_success + expect(section.reload.name).to eq("Updated name") + end + end + + context "when called by a non-admin" do + subject(:call) { described_class.new(user: user, model: section).call(name: "Updated name") } + + it "fails authorization" do + expect(call).not_to be_success + expect(section.reload.name).to eq("Original name") + end + end + + context "with invalid attributes" do + subject(:call) { described_class.new(user: admin, model: section).call(name: "") } + + it "fails validation" do + expect(call).not_to be_success + expect(call.result.errors[:name]).to be_present + end + end + + context "when moving position" do + let!(:section_a) { create(:user_custom_field_section, position: 1) } + let!(:section_b) { create(:user_custom_field_section, position: 2) } + + it "reorders sections when moving up" do + described_class.new(user: admin, model: section_b).call(move_to: :highest) + expect(section_b.reload.position).to eq(1) + end + end +end diff --git a/spec/support/cuprite_setup.rb b/spec/support/cuprite_setup.rb index 7576233d1cce..4b3bd524b5fa 100644 --- a/spec/support/cuprite_setup.rb +++ b/spec/support/cuprite_setup.rb @@ -142,6 +142,23 @@ def configure_remote_chrome(options) options end +# Ferrum v0.17.2 regression: https://github.com/rubycdp/ferrum/issues/578 +Ferrum::Contexts.prepend(Module.new do + def reset + super + @default_context = nil + end + + private + + def add_context(context_id) + return if contexts[context_id] + + context = Ferrum::Context.new(@client, self, context_id) + contexts[context_id] = context + end +end) + register_better_cuprite "en" RSpec.configure do |config| diff --git a/spec/support/pages/admin/settings/user_custom_fields/index.rb b/spec/support/pages/admin/settings/user_custom_fields/index.rb new file mode 100644 index 000000000000..038cbc6f053c --- /dev/null +++ b/spec/support/pages/admin/settings/user_custom_fields/index.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. +#++ + +module Pages + module Admin + module Settings + module UserCustomFields + class Index < ::Pages::Page + def path + "/admin/settings/user_custom_fields" + end + + def visit_page(customizable_name) + fail "Only User type is expected" unless customizable_name == "Users" + + visit! + end + + def expect_add_user_attribute_submenu(close: true) + within_add_menu(close:) do + expect(page).to have_test_selector("add-user-custom-field-attribute") + end + end + + def expect_no_add_user_attribute_submenu(close: true) + within_add_menu(close:) do + expect(page).to have_no_test_selector("add-user-custom-field-attribute") + end + end + + def click_to_create_new_custom_field(type) + within_add_menu do + click_button "User attribute" + click_on type + end + wait_for_network_idle + end + + private + + def within_add_menu(close: false, &) + wait_for_network_idle + + button = find_button("Add") + button.click + within(button.ancestor("action-menu").find("action-list"), &) + button.click if close + end + end + end + end + end +end