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/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..f080aa2080ce 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 @@ -64,7 +64,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)) 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..85ea73709778 --- /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("settings.user_attributes.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..e9e3cbd19e15 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,111 @@ +# 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_and_last:) + super + + @user_custom_field = user_custom_field + @first_and_last = first_and_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 + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @user_custom_field + else + @user_custom_field.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @user_custom_field + else + @user_custom_field.last? + 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..60b62339aa1a --- /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_attributes.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..49b6f3cd438e --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -0,0 +1,89 @@ +<%= + 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_attributes.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("settings.user_attributes.label_section_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + if @user_custom_fields.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + 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_attributes.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 @user_custom_fields.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 + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.user_attributes.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_attributes.label_new_attribute") + ) do |btn| + btn.with_leading_visual_icon(icon: :plus) + btn.with_trailing_visual_icon(icon: :"triangle-down") + + t("settings.user_attributes.label_new_attribute") + end + + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .map do |format| + action_menu_item_for_custom_field_format(menu, format) + end + end + end + end + end + else + first_and_last = [@user_custom_fields.first, @user_custom_fields.last] + @user_custom_fields.each do |user_custom_field| + component.with_row( + data: draggable_item_config(user_custom_field) + ) do + render( + custom_field_row_component_class.new( + user_custom_field:, + first_and_last: + ) + ) + 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..255d116ced28 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.rb @@ -0,0 +1,162 @@ +# 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 + @user_custom_fields = user_custom_field_section.custom_fields + + @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": "custom-field" + } + end + + def draggable_item_config(user_custom_field) + { + "draggable-id": user_custom_field.id, + "draggable-type": "custom-field", + "drop-url": drop_admin_settings_user_custom_field_path(user_custom_field) + } + 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("settings.user_attributes.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..ef50115303c5 --- /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_attributes.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..329e169e6bc2 --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -0,0 +1,56 @@ +<%= component_wrapper do %> + <%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title(variant: :default) { t("settings.user_attributes.heading") } + header.with_description { t("settings.user_attributes.heading_description") } + header.with_breadcrumbs(breadcrumbs_items) + end + %> + + <%= + render Primer::OpenProject::SubHeader.new do |subheader| + 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_attributes.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_attributes.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..af1beed71163 --- /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_attributes.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..38bd818ba424 --- /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_attributes.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_attributes.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format), + t("settings.user_attributes.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..60aa9332d8c6 --- /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..b0e252a59006 --- /dev/null +++ b/app/components/users/form/custom_field_section_component.html.erb @@ -0,0 +1,7 @@ +
+ <%= title %> + + <% @fields.each do |custom_field| %> + <%= render Users::Form::CustomFieldFieldComponent.new(custom_field:, form: @form) %> + <% 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..d809e21bb326 --- /dev/null +++ b/app/components/users/form/custom_field_section_component.rb @@ -0,0 +1,46 @@ +# 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:, fields:, form:) + super() + @section = section + @fields = fields + @form = form + end + + def title + @section.name.presence || I18n.t("settings.user_attributes.label_untitled_section") + end + end + end +end diff --git a/app/components/users/form/custom_field_sections_component.html.erb b/app/components/users/form/custom_field_sections_component.html.erb new file mode 100644 index 000000000000..924f490130f5 --- /dev/null +++ b/app/components/users/form/custom_field_sections_component.html.erb @@ -0,0 +1,3 @@ +<% sections.each do |section| %> + <%= render section %> +<% end %> diff --git a/app/components/users/form/custom_field_sections_component.rb b/app/components/users/form/custom_field_sections_component.rb new file mode 100644 index 000000000000..229100b7ae46 --- /dev/null +++ b/app/components/users/form/custom_field_sections_component.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 Users + module Form + class CustomFieldSectionsComponent < ApplicationComponent + def initialize(form:) + super() + @form = form + end + + def sections + @sections ||= begin + visible_cf_ids = @form.object.visible_custom_field_values.map(&:custom_field_id) + + UserCustomFieldSection + .with_custom_fields(visible_cf_ids) + .map do |section| + Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: @form) + end + end + 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..80fac9aba006 100644 --- a/app/components/users/hover_card_component.html.erb +++ b/app/components/users/hover_card_component.html.erb @@ -57,6 +57,17 @@ See COPYRIGHT and LICENSE files for more details. end end + card_custom_field_values.group_by(&:custom_field).each do |custom_field, values| + formatted = format_multi_values(values) + + 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(font_weight: :bold, mr: 2) { custom_field.name } + cf.with_column { formatted } + end + end + end + flex.with_row do flex_layout(classes: "op-user-hover-card--group-list") do |f| f.with_column do diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index 014639f18510..132700eccc89 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,28 @@ def group_membership_summary(max_length = 40) build_summary(group_links, cutoff_index) end + def card_custom_field_values + @user.custom_values + .eager_load(:custom_field) + .joins("INNER JOIN custom_field_sections ON custom_field_sections.id = custom_fields.custom_field_section_id") + .where(custom_fields: { visible_on_user_card: true }) + .where.not(value: [nil, ""]) + .then { |scope| User.current.active_admin? ? scope : scope.where(custom_fields: { admin_only: false }) } + .order("custom_field_sections.position", "custom_fields.position_in_custom_field_section") + end + private + def format_multi_values(values) + formatted = values.map(&:formatted_value) + visible = safe_join(formatted.first(MULTI_VALUE_DISPLAY_LIMIT), ", ") + remaining = formatted.size - MULTI_VALUE_DISPLAY_LIMIT + + return visible if remaining <= 0 + + safe_join([visible, t("custom_fields.multi_value_more", count: remaining)], " ") + 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 index 0b0c51447258..2aca2d14ee62 100644 --- a/app/components/users/profile/attributes_component.html.erb +++ b/app/components/users/profile/attributes_component.html.erb @@ -14,17 +14,23 @@ 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 + sections_with_fields.each do |custom_field_section, fields| + details_container.with_row(mt: 3, font_weight: :bold, color: :subtle) do + custom_field_section.name.presence || t("settings.user_attributes.label_untitled_section") end - details_container.with_row do - value = @user.formatted_custom_value_for(custom_field) - if value.is_a?(Array) - safe_join(value, ", ") - else - value + 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 diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_component.rb index 50f7b10ed196..99352000a603 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_component.rb @@ -42,16 +42,20 @@ def initialize(user:) end def render? - user_is_allowed_to_see_email || @user.visible_custom_field_values.any? { it.value.present? } + user_is_allowed_to_see_email || sections_with_fields.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 sections_with_fields + @sections_with_fields ||= begin + filled_cf_ids = @user + .visible_custom_field_values + .select { |cv| cv.value.present? } + .map(&:custom_field_id) + + UserCustomFieldSection + .with_custom_fields(filled_cf_ids) + .map { |section| [section, section.custom_fields] } + end end def user_is_allowed_to_see_email 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..8adf44c43f68 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) 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..ff631a6e2efd --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections_controller.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. +#++ + +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) + # TODO: show error message on failure + 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) + # TODO: show error message on failure + 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) + # TODO: show error message on failure + end + + respond_with_turbo_streams + end + + def new_link + respond_with_dialog Settings::UserCustomFieldSections::NewSectionDialogComponent.new + end + + private + + 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..01ba71f2608e --- /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[: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..4c9eed3411cb --- /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::UpdateService.new(user: current_user, model: @custom_field).call( + move_to: params.expect(:move_to)&.to_sym + ) + + 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 = ::UserCustomFields::DropService.new(user: current_user, 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/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 58d9a81828be..59ec5efdf089 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_attributes.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/user_custom_field_sections/name_form.rb b/app/forms/user_custom_field_sections/name_form.rb new file mode 100644 index 000000000000..b3381c123eac --- /dev/null +++ b/app/forms/user_custom_field_sections/name_form.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. +#++ + +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 + ) + end +end 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/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/user_custom_field.rb b/app/models/user_custom_field.rb index f9cc648a8eda..1c36aaa70ad5 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -29,6 +29,13 @@ #++ class UserCustomField < CustomField + belongs_to :user_custom_field_section, class_name: "UserCustomFieldSection", foreign_key: :custom_field_section_id, + inverse_of: :custom_fields + + acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] + + validates :custom_field_section_id, presence: true + 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..afb383a6f119 --- /dev/null +++ b/app/models/user_custom_field_section.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. +#++ + +class UserCustomFieldSection < CustomFieldSection + scope :with_custom_fields, ->(ids) { + joins(:custom_fields) + .where(custom_fields: { id: ids }) + .includes(:custom_fields) + .order("custom_field_sections.position", "custom_fields.position_in_custom_field_section") + } + + def untitled? + name.blank? + end + + has_many :custom_fields, -> { order(position_in_custom_field_section: :asc) }, + class_name: "UserCustomField", + dependent: :destroy, + foreign_key: :custom_field_section_id, + inverse_of: :user_custom_field_section +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/services/user_custom_fields/drop_service.rb b/app/services/user_custom_fields/drop_service.rb new file mode 100644 index 000000000000..98967700e126 --- /dev/null +++ b/app/services/user_custom_fields/drop_service.rb @@ -0,0 +1,95 @@ +# 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 UserCustomFields + class DropService < ::BaseServices::BaseCallable + def initialize(user:, user_custom_field:) + super() + @user = user + @user_custom_field = user_custom_field + end + + def perform + service_call = validate_permissions + service_call = perform_drop(service_call, params) if service_call.success? + + service_call + end + + def validate_permissions + if @user.admin? + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + 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 + + service_call + end + + private + + def check_and_update_section_if_changed(params) + current_section = @user_custom_field.user_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 + + def update_section(new_section_id) + current_section = UserCustomFieldSection.find(new_section_id) + @user_custom_field.remove_from_list + @user_custom_field.update!(user_custom_field_section: current_section) + current_section + end + + def update_position(new_position) + @user_custom_field.insert_at(new_position) + end + 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..a37fef0fb325 --- /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_attributes.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..1cf89c4ec92e --- /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_attributes.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..6e51fd751655 --- /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_attributes.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..faa3cccd0b4c --- /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_attributes.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..f50dbb6087dc --- /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_attributes.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..7e3adfb1c150 --- /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_attributes.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..81b45f52b56e --- /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_attributes.heading"), t("settings.user_attributes.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..51f3a07539fa --- /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_attributes.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..0180cd39d6e2 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -36,7 +36,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render partial: "users/form/custom_fields", locals: { f: f } %> + <%= render Users::Form::CustomFieldSectionsComponent.new(form: f) %> <%= 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..f20cfe520630 100644 --- a/app/views/users/_simple_form.html.erb +++ b/app/views/users/_simple_form.html.erb @@ -35,6 +35,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render partial: "users/form/custom_fields", locals: { f: f } %> + <%= render Users::Form::CustomFieldSectionsComponent.new(form: f) %> <%= call_hook(:view_users_form, user: @user, form: f) %> 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/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 4350f452c769..277f424edbe3 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -354,6 +354,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 8bbaec534912..52109ebd0b98 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -717,6 +717,8 @@ en: reorder_confirmation: "Warning: The current order of available values as well as all unsaved values will be lost. Are you sure you want to continue?" placeholder_version_select: "Work package or project selection is required first" calculated_field_not_editable: "Non-editable attribute. This value is calculated automatically." + multi_value_more: + other: "and %{count} more" no_role_assigment: "No role assignment" instructions: is_required: @@ -750,6 +752,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. @@ -1848,6 +1852,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: @@ -2064,6 +2069,8 @@ en: 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" @@ -4570,6 +4577,7 @@ en: label_user_anonymous: "Anonymous" label_user_menu: "User menu" label_user_new: "New user" + label_user_attributes_plural: "User attributes" label_user_plural: "Users" label_user_search: "Search for user" label_user_settings: "User settings" @@ -5663,6 +5671,18 @@ en: side_panel: label: "Side panel" description: "Add all the project attributes in a section inside the right side panel in the project overview." + user_attributes: + heading: "User attributes" + 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." + label_edit_section: "Edit title" + label_new_attribute: "User attribute" + label_new_section: "Section" + label_no_user_custom_fields: "No user attributes defined in this section" + label_section_actions: "Section actions" + label_untitled_section: "Untitled section" + label_user_custom_field_actions: "User attribute actions" + new: + heading: "New attribute" project_initiation_request: header_description: > OpenProject can generate a step-by-step wizard to help project managers fill out a project initiation request. diff --git a/config/routes.rb b/config/routes.rb index e9cb70bb5469..d9b9e1a37228 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -754,6 +754,44 @@ 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 + 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/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/spec/components/users/form/custom_field_sections_component_spec.rb b/spec/components/users/form/custom_field_sections_component_spec.rb new file mode 100644 index 000000000000..0a400d7cb396 --- /dev/null +++ b/spec/components/users/form/custom_field_sections_component_spec.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. +#++ + +require "rails_helper" + +RSpec.describe Users::Form::CustomFieldSectionsComponent, type: :component do + let(:user) { create(:user) } + let(:form) { instance_double(ActionView::Helpers::FormBuilder, object: user) } + let(:component) { described_class.new(form:) } + + describe "#sections" do + subject { component.sections } + + context "with no sections" do + it { is_expected.to be_empty } + end + + context "with a section containing visible fields" do + let!(:section) { create(:user_custom_field_section) } + let!(:field) { create(:user_custom_field, :string, user_custom_field_section: section) } + + it "returns one section component" do + expect(subject.size).to eq(1) + expect(subject.first).to be_a(Users::Form::CustomFieldSectionComponent) + end + end + + context "with a section containing only admin_only fields" do + let!(:section) { create(:user_custom_field_section) } + let!(:field) { create(:user_custom_field, :string, user_custom_field_section: section, admin_only: true) } + + it "excludes the section for a non-admin user" do + expect(subject).to be_empty + end + end + + context "with multiple sections" do + let!(:section_a) { create(:user_custom_field_section, position: 1) } + let!(:section_b) { create(:user_custom_field_section, position: 2) } + let!(:field_a) { create(:user_custom_field, :string, user_custom_field_section: section_a) } + let!(:field_b) { create(:user_custom_field, :string, user_custom_field_section: section_b) } + + it "returns sections in position order" do + names = subject.map { |s| s.instance_variable_get(:@section) } + expect(names).to eq([section_a, section_b]) + end + 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..440e7efd625e 100644 --- a/spec/components/users/hover_card_component_spec.rb +++ b/spec/components/users/hover_card_component_spec.rb @@ -164,4 +164,84 @@ end end end + + context "when displaying custom fields" do + let(:section) { create(:user_custom_field_section) } + let!(:visible_cf) do + create(:user_custom_field, :string, name: "Job title", + user_custom_field_section: section, visible_on_user_card: true) + end + let!(:hidden_cf) do + create(:user_custom_field, :string, name: "Hidden field", + user_custom_field_section: section, visible_on_user_card: false) + end + let!(:admin_only_cf) do + create(:user_custom_field, :string, name: "Secret field", + user_custom_field_section: section, visible_on_user_card: true, admin_only: true) + end + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: [ + build(:custom_value, custom_field: visible_cf, value: "Developer"), + build(:custom_value, custom_field: hidden_cf, value: "Should not appear"), + build(:custom_value, custom_field: admin_only_cf, value: "Top secret") + ]) + end + + it "shows fields flagged as visible_on_user_card" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Developer") + end + + it "does not show fields with visible_on_user_card: false" do + expect(page).to have_no_text("Should not appear") + end + + it "does not show admin_only fields to non-admins" do + expect(page).to have_no_text("Top secret") + end + + context "when current user is admin" do + let(:current_user) { build(:admin) } + + it "shows admin_only fields flagged as visible_on_user_card" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Top secret") + end + 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" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Ruby, Rails, React") + end + + it "appends the ellipsis wording for the remaining values" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "and 2 more") + end + end + + context "when the value is blank" do + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: [build(:custom_value, custom_field: visible_cf, value: "")]) + end + + it "does not render the field" do + expect(page).to have_no_test_selector("user-hover-card-custom-field") + end + end + end end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index 9c8d48f00691..9bf42a3e2c3d 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -41,9 +41,7 @@ subject { component.render? } context "when user has view_user_email permission" do - before do - create(:standard_global_role) - end + before { create(:standard_global_role) } it { is_expected.to be(true) } end @@ -80,50 +78,115 @@ 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:) } + describe "rendering custom fields" do + let(:section) { create(:user_custom_field_section, name: "Profile info") } + let(:custom_field) { create(:user_custom_field, :string, admin_only:, user_custom_field_section: section) } + let(:admin_only) { false } + let(:user) { build_stubbed(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } current_user { build(:admin) } - before do - render_inline(component) - end + before { render_inline(component) } - it "renders the field" do + it "renders the field value" do expect(page).to have_text("Hello custom field") end - context "when not visible" do + it "renders the section name as a heading" do + expect(page).to have_text("Profile info") + end + + context "when admin_only and current user is not admin" do let(:admin_only) { true } + current_user { logged_in_user } + 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.") - ] + context "with an untitled section" do + let(:section) do + create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } 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 + it "renders the I18n fallback label" do + expect(page).to have_text(I18n.t("settings.user_attributes.label_untitled_section")) + end + end + + context "with fields in two sections" do + let(:section_first) { create(:user_custom_field_section, name: "First", position: 1) } + let(:section_second) { create(:user_custom_field_section, name: "Second", position: 2) } + let(:field_in_first) { create(:user_custom_field, :string, name: "In first", user_custom_field_section: section_first) } + let(:field_in_second) { create(:user_custom_field, :string, name: "In second", user_custom_field_section: section_second) } + let(:user) do + build_stubbed(:user, custom_values: [ + build(:custom_value, custom_field: field_in_first, value: "Value A"), + build(:custom_value, custom_field: field_in_second, value: "Value B") + ]) + end + + it "renders section headings in position order" do + expect(page.text).to match(/First.*Second/m) + end + + it "renders each field under its own section heading" do + expect(page.text).to match(/First.*In first.*Second.*In second/m) + end + end + + context "with multiple fields in one section" do + let(:first_field) do + create(:user_custom_field, :string, name: "Field 1", user_custom_field_section: section, + position_in_custom_field_section: 1) + end + let(:second_field) do + create(:user_custom_field, :string, name: "Field 2", user_custom_field_section: section, + position_in_custom_field_section: 2) + end + let(:user) do + build_stubbed(:user, custom_values: [ + build(:custom_value, custom_field: first_field, value: "First value"), + build(:custom_value, custom_field: second_field, value: "Second value") + ]) + end + + it "renders fields in position_in_custom_field_section order" do + items = page.all(:test_id, "user-custom-field") + expect(items[0]).to have_text("Field 1") + expect(items[1]).to have_text("Field 2") + end + end + + context "with multi-select and formattable fields" do + let(:list_field) do + create(:user_custom_field, :multi_list, name: "Ze list", user_custom_field_section: section, + position_in_custom_field_section: 2) + end + let(:text_field) do + create(:user_custom_field, :text, name: "A portrait", user_custom_field_section: section, + position_in_custom_field_section: 1) + end + let(:user) do + build_stubbed(:user, custom_values: [ + build(:custom_value, custom_field: list_field, value: list_field.possible_values[0]), + build(:custom_value, custom_field: list_field, value: list_field.possible_values[1]), + build(:custom_value, custom_field: list_field, value: list_field.possible_values[2]), + build(:custom_value, custom_field: text_field, value: "This is **formatted** text.") + ]) + end + + it "renders multi-select values as a comma-separated list" do expect(page).to have_text("A, B, C") - # alphabetical order + end + + it "renders formattable fields as HTML" do + expect(page).to have_css("strong", text: "formatted") + end + + it "orders fields by position_in_custom_field_section, not alphabetically" do items = page.all(:test_id, "user-custom-field") expect(items[0]).to have_text("A portrait") expect(items[1]).to have_text("Ze list") 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/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/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..f7dc094b29e0 --- /dev/null +++ b/spec/features/admin/custom_fields/users/index_spec.rb @@ -0,0 +1,220 @@ +# 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 + 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/list_spec.rb b/spec/features/admin/custom_fields/users/list_spec.rb index ade785e1116c..7c36fa83b1a3 100644 --- a/spec/features/admin/custom_fields/users/list_spec.rb +++ b/spec/features/admin/custom_fields/users/list_spec.rb @@ -32,7 +32,66 @@ require_relative "../shared_custom_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 } + + 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 custom field's format", "Users", "List" 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/models/user_custom_field_section_spec.rb b/spec/models/user_custom_field_section_spec.rb new file mode 100644 index 000000000000..2530dbec3632 --- /dev/null +++ b/spec/models/user_custom_field_section_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe UserCustomFieldSection do + 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) do + create(:user_custom_field, name: "A1", user_custom_field_section: section_a, position_in_custom_field_section: 1) + end + let!(:field_a2) do + create(:user_custom_field, name: "A2", user_custom_field_section: section_a, position_in_custom_field_section: 2) + end + let!(:field_b1) do + create(:user_custom_field, name: "B1", user_custom_field_section: section_b, position_in_custom_field_section: 1) + end + + it "returns only sections that have 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 "loads 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 + + it "orders fields within a section by position_in_custom_field_section" do + result = described_class.with_custom_fields([field_a1.id, field_a2.id]) + expect(result.first.custom_fields).to eq([field_a1, field_a2]) + 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 +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