diff --git a/app/components/custom_fields/details_component.rb b/app/components/custom_fields/details_component.rb index 0eb53f09011e..320c4a4a1c3c 100644 --- a/app/components/custom_fields/details_component.rb +++ b/app/components/custom_fields/details_component.rb @@ -45,9 +45,17 @@ class DetailsComponent < ApplicationComponent def form_url if model.new_record? - model.type == "ProjectCustomField" ? admin_settings_project_custom_fields_path : custom_fields_path + case model.type + when "ProjectCustomField" then admin_settings_project_custom_fields_path + when "UserCustomField" then admin_settings_user_custom_fields_path + else custom_fields_path + end else - model.type == "ProjectCustomField" ? admin_settings_project_custom_field_path(model) : custom_field_path(model) + case model.type + when "ProjectCustomField" then admin_settings_project_custom_field_path(model) + when "UserCustomField" then admin_settings_user_custom_field_path(model) + else custom_field_path(model) + end end end diff --git a/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb b/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb index 075ce7dbd376..48fc90689f3e 100644 --- a/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb @@ -42,8 +42,9 @@ def initialize(project:, project_custom_field_section:) @project = project @project_custom_field_section = project_custom_field_section - enabled_custom_field_ids = project.enabled_custom_field_ids - @project_custom_fields = project_custom_field_section.custom_fields.where(id: enabled_custom_field_ids) + enabled_ids = project.enabled_custom_field_ids.to_set + @project_custom_fields = project_custom_field_section.custom_fields_in_order + .select { |cf| enabled_ids.include?(cf.id) } end private diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb index 1a5428694eb8..a5de0894e196 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -41,7 +41,7 @@ def initialize(project:, project_custom_field_section:) @project = project @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields + @project_custom_fields = project_custom_field_section.custom_fields_in_order end private diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index a0167b860435..c5cb8f75ce93 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -35,11 +35,12 @@ class CustomFieldRowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project_custom_field:, first_and_last:) - super + def initialize(project_custom_field:, first:, last:) + super() @project_custom_field = project_custom_field - @first_and_last = first_and_last + @first = first + @last = last end private @@ -101,21 +102,11 @@ def delete_action_item(menu) end def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @project_custom_field - else - @project_custom_field.first? - end + @first end def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @project_custom_field - else - @project_custom_field.last? - end + @last end end end diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb index 4b3e3f0e6849..f57827bca4e7 100644 --- a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb @@ -51,7 +51,12 @@ def form_config { model: @project_custom_field_section, method: @project_custom_field_section.persisted? ? :put : :post, - url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path + url: if @project_custom_field_section.persisted? + admin_settings_project_custom_field_section_path(@project_custom_field_section) + else + admin_settings_project_custom_field_sections_path + end, + data: { turbo_stream: true } } end end diff --git a/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb index 211c3cdf346a..6a81fb54d622 100644 --- a/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb +++ b/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb @@ -2,7 +2,8 @@ Primer::Alpha::Dialog.new( title: t("settings.project_attributes.label_new_section"), size: :medium_portrait, - id: MODAL_ID + id: MODAL_ID, + data: { "keep-open-on-submit": true } ) ) do %> <%= render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new) %> diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index 6ce3bf4f9699..dad90516a72b 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -52,11 +52,28 @@ menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.project_attributes.label_section_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) - if @project_custom_fields.empty? + if @ordered_cfs.empty? delete_action_item(menu) else disabled_delete_action_item(menu) end + + menu.with_divider + + menu.with_sub_menu_item( + label: t("settings.project_attributes.label_new_attribute"), + content_arguments: { + aria: { label: t("settings.project_attributes.label_add_attribute") }, + data: { test_selector: "new-project-custom-field-in-section-submenu" } + } + ) do |sub_menu| + sub_menu.with_leading_visual_icon(icon: :plus) + OpenProject::CustomFieldFormat.available_for_class_name("Project") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(sub_menu, format) + end + end end end @@ -64,7 +81,8 @@ render( Primer::Alpha::Dialog.new( id: "project-custom-field-section-dialog#{@project_custom_field_section.id}", title: t("settings.project_attributes.label_new_section"), - size: :medium_portrait + size: :medium_portrait, + data: { "keep-open-on-submit": true } ) ) do |_dialog| render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) @@ -73,7 +91,7 @@ end end end - if @project_custom_fields.empty? + if @ordered_cfs.empty? component.with_row(data: { "empty-list-item": true }) do flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| empty_list_container.with_column(ml: 4, mr: 2) do @@ -93,23 +111,21 @@ OpenProject::CustomFieldFormat.available_for_class_name("Project") .sort_by(&:name) - .map do |format| - action_menu_item_for_custom_field_format(menu, format) + .each do |format| + action_menu_item_for_custom_field_format(menu, format) end end end end end else - first_and_last = [@project_custom_fields.first, @project_custom_fields.last] - @project_custom_fields.each do |project_custom_field| - component.with_row( - data: draggable_item_config(project_custom_field) - ) do + @ordered_cfs.each_with_index do |cf, index| + component.with_row(data: draggable_item_config(cf)) do render( custom_field_row_component_class.new( - project_custom_field:, - first_and_last: + project_custom_field: cf, + first: index == 0, + last: index == @ordered_cfs.size - 1 ) ) end diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index d6c5203de2f9..be36c7fecfc7 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -39,7 +39,7 @@ def initialize(project_custom_field_section:, first_and_last: []) super @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields + @ordered_cfs = project_custom_field_section.custom_fields_in_order @first_and_last = first_and_last end @@ -57,9 +57,9 @@ def wrapper_uniq_by def drag_and_drop_target_config { generic_drag_and_drop_target: "container", - "target-container-accessor": ".Box > ul", # the accessor of the container that contains the drag and drop items - "target-id": @project_custom_field_section.id, # the id of the target - "target-allowed-drag-type": "custom-field" # the type of dragged items which are allowed to be dropped in this target + "target-container-accessor": ".Box > ul", + "target-id": @project_custom_field_section.id, + "target-allowed-drag-type": "custom-field" } end @@ -132,21 +132,23 @@ def delete_action_item(menu) end def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @project_custom_field_section - else - @project_custom_field_section.first? - end + return @first unless @first.nil? + + @first = if @first_and_last.first + @first_and_last.first == @project_custom_field_section + else + @project_custom_field_section.first? + end end def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @project_custom_field_section - else - @project_custom_field_section.last? - end + return @last unless @last.nil? + + @last = if @first_and_last.last + @first_and_last.last == @project_custom_field_section + else + @project_custom_field_section.last? + end end def action_menu_item_for_custom_field_format(menu, format) diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb new file mode 100644 index 000000000000..2f5ae324e5ee --- /dev/null +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb @@ -0,0 +1,32 @@ +<%= + component_wrapper( + class: "op-user-built-in-attribute-container", + data: { test_selector: "user-built-in-attribute-container-#{@attribute_key}" } + ) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_weight: :bold)) { attribute_label } + end + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :secondary, size: :medium)) do + t(".label_built_in") + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "built-in-attribute-action-menu" })) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + "aria-label": t(".label_built_in_attribute_actions"), + scheme: :invisible + ) + move_actions(menu) + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb new file mode 100644 index 000000000000..3b442fc27b82 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFieldSections + class BuiltInAttributeRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(section:, attribute_key:, first:, last:) + super() + @section = section + @attribute_key = attribute_key + @first = first + @last = last + end + + def attribute_label + User.human_attribute_name(@attribute_key) + end + + private + + def move_actions(menu) + unless @first + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless @last + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item( + label: label_text, + href: move_admin_settings_user_custom_field_section_built_in_attribute_path( + @section, @attribute_key, move_to: + ), + form_arguments: { + method: :put, + data: { "turbo-stream": true, test_selector: "built-in-attribute-move-#{move_to}" } + } + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb new file mode 100644 index 000000000000..d7382d0fbea9 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb @@ -0,0 +1,43 @@ +<%= + component_wrapper(class: "op-user-custom-field-container", data: { test_selector: "user-custom-field-container-#{@user_custom_field.id}" }) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column(mr: 2) do + render( + Primer::Beta::Link.new( + href: edit_admin_settings_user_custom_field_path(@user_custom_field), + underline: false, + font_weight: :bold, + data: { turbo: "false" } + ) + ) do + @user_custom_field.name + end + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + helpers.label_for_custom_field_format(@user_custom_field.field_format) + end + end + if @user_custom_field.required? + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + UserCustomField.human_attribute_name(:required) + end + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(".label_user_custom_field_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + delete_action_item(menu) + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.rb b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..854ebc0b05b9 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field:, first:, last:) + super() + @user_custom_field = user_custom_field + @first = first + @last = last + end + + private + + def edit_action_item(menu) + menu.with_item(label: t("label_edit"), + href: edit_admin_settings_user_custom_field_path(@user_custom_field), + data: { turbo: "false", test_selector: "user-custom-field-edit" }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_actions(menu) + unless @first + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless @last + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_user_custom_field_path(@user_custom_field, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, test_selector: "user-custom-field-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_user_custom_field_path(@user_custom_field), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t(:text_are_you_sure), + turbo_stream: true, + test_selector: "user-custom-field-delete" + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb new file mode 100644 index 000000000000..3001cdd1b4e5 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb @@ -0,0 +1,23 @@ +<%= + component_wrapper do + primer_form_with(**form_config) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new) do + render(UserCustomFieldSections::NameForm.new(f)) + end + + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |modal_footer| + modal_footer.with_component(Primer::Beta::Button.new(data: { "close-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}" })) do + t("button_cancel") + end + + modal_footer.with_component(Primer::Beta::Button.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb new file mode 100644 index 000000000000..78e7f62792c0 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFieldSections + class DialogBodyFormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_section: UserCustomFieldSection.new) + super + + @user_custom_field_section = user_custom_field_section + end + + private + + def wrapper_uniq_by + @user_custom_field_section.id + end + + def form_config + { + model: @user_custom_field_section, + method: @user_custom_field_section.persisted? ? :put : :post, + url: if @user_custom_field_section.persisted? + admin_settings_user_custom_field_section_path(@user_custom_field_section) + else + admin_settings_user_custom_field_sections_path + end, + data: { turbo_stream: true } + } + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/index_component.html.erb b/app/components/settings/user_custom_field_sections/index_component.html.erb new file mode 100644 index 000000000000..a496772cc3ee --- /dev/null +++ b/app/components/settings/user_custom_field_sections/index_component.html.erb @@ -0,0 +1,15 @@ +<%= + component_wrapper(data: wrapper_data_attributes) do + if @user_custom_field_sections.any? + flex_layout(classes: "dragula-container", data: { "allowed-drop-type": "section" }.merge(drop_target_config)) do |flex| + @user_custom_field_sections.each do |section| + flex.with_row( + data: draggable_item_config(section) + ) do + render(row_component_class.new(user_custom_field_section: section, first_and_last:)) + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/index_component.rb b/app/components/settings/user_custom_field_sections/index_component.rb new file mode 100644 index 000000000000..24fddff4f476 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/index_component.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_sections:) + super + + @user_custom_field_sections = user_custom_field_sections + end + + def row_component_class + Settings::UserCustomFieldSections::ShowComponent + end + + def first_and_last + [@user_custom_field_sections.first, @user_custom_field_sections.last] + end + + private + + def wrapper_data_attributes + { + controller: "generic-drag-and-drop" + } + end + + def drop_target_config + { + generic_drag_and_drop_target: "container", + "target-allowed-drag-type": "section" + } + end + + def draggable_item_config(section) + { + "draggable-id": section.id, + "draggable-type": "section", + "drop-url": drop_admin_settings_user_custom_field_section_path(section) + } + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb new file mode 100644 index 000000000000..48fddf2228c8 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb @@ -0,0 +1,10 @@ +<%= render( + Primer::Alpha::Dialog.new( + title: t("settings.user_custom_fields.label_new_section"), + size: :medium_portrait, + id: MODAL_ID, + data: { "keep-open-on-submit": true } + ) + ) do %> + <%= render(Settings::UserCustomFieldSections::DialogBodyFormComponent.new) %> +<% end %> diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb new file mode 100644 index 000000000000..e91c9bae363b --- /dev/null +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFieldSections + class NewSectionDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + MODAL_ID = "user-custom-field-section-dialog" + FORM_ID = "user-custom-field-section-dialog-form" + end + end +end diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb new file mode 100644 index 000000000000..55234ff31a83 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -0,0 +1,120 @@ +<%= + component_wrapper(class: "op-user-custom-field-section-container", data: { test_selector: "user-custom-field-section-container-#{@user_custom_field_section.id}" }) do + render(border_box_container(mt: 3, data: drag_and_drop_target_config)) do |component| + component.with_header(font_weight: :bold) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + @user_custom_field_section.name.presence || t("settings.user_custom_fields.label_untitled_section") + end + end + end + + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-section-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(".label_section_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + if @ordered_attributes.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + end + + menu.with_divider + + menu.with_sub_menu_item( + label: t("settings.user_custom_fields.label_new_attribute"), + content_arguments: { + aria: { label: t(".label_add_attribute") }, + data: { test_selector: "new-user-custom-field-in-section-submenu" } + } + ) do |sub_menu| + sub_menu.with_leading_visual_icon(icon: :plus) + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(sub_menu, format) + end + end + end + end + + actions_container.with_column do + render( + Primer::Alpha::Dialog.new( + id: "user-custom-field-section-dialog#{@user_custom_field_section.id}", title: t("settings.user_custom_fields.label_new_section"), + size: :medium_portrait, + data: { "keep-open-on-submit": true } + ) + ) do |_dialog| + render(Settings::UserCustomFieldSections::DialogBodyFormComponent.new(user_custom_field_section: @user_custom_field_section)) + end + end + end + end + end + if @ordered_attributes.empty? + component.with_row(data: { "empty-list-item": true, "filter--filter-list-target": "searchItem" }) do + flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| + empty_list_container.with_column(ml: 4, mr: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t(".label_no_user_custom_fields") } + end + empty_list_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "new-user-custom-field-in-section-button" })) do |menu| + menu.with_show_button( + scheme: :secondary, + "aria-label": t("settings.user_custom_fields.label_new_attribute") + ) do |btn| + btn.with_leading_visual_icon(icon: :plus) + btn.with_trailing_visual_icon(icon: :"triangle-down") + + t("settings.user_custom_fields.label_new_attribute") + end + + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(menu, format) + end + end + end + end + end + else + @ordered_attributes.each_with_index do |key, index| + first = index == 0 + last = index == @ordered_attributes.size - 1 + + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + component.with_row(data: draggable_item_config_for_built_in(key).merge("filter--filter-list-target": "searchItem")) do + render( + Settings::UserCustomFieldSections::BuiltInAttributeRowComponent.new( + section: @user_custom_field_section, + attribute_key: key, + first:, + last: + ) + ) + end + elsif (cf = @custom_fields_by_key[key]) + component.with_row(data: draggable_item_config_for_custom_field(cf).merge("filter--filter-list-target": "searchItem")) do + render( + custom_field_row_component_class.new( + user_custom_field: cf, + first:, + last: + ) + ) + end + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/show_component.rb b/app/components/settings/user_custom_field_sections/show_component.rb new file mode 100644 index 000000000000..a685cb715e9d --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFieldSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_section:, first_and_last: []) + super + + @user_custom_field_section = user_custom_field_section + @ordered_attributes = user_custom_field_section.attribute_order + @custom_fields_by_key = user_custom_field_section.custom_fields_by_key + + @first_and_last = first_and_last + end + + def custom_field_row_component_class + Settings::UserCustomFieldSections::CustomFieldRowComponent + end + + private + + def wrapper_uniq_by + @user_custom_field_section.id + end + + def drag_and_drop_target_config + { + generic_drag_and_drop_target: "container", + "target-container-accessor": ".Box > ul", + "target-id": @user_custom_field_section.id, + "target-allowed-drag-type": "user-field" + } + end + + def draggable_item_config_for_custom_field(user_custom_field) + { + "draggable-id": user_custom_field.id, + "draggable-type": "user-field", + "drop-url": drop_admin_settings_user_custom_field_path(user_custom_field) + } + end + + def draggable_item_config_for_built_in(key) + { + "draggable-id": key, + "draggable-type": "user-field", + "drop-url": drop_admin_settings_user_custom_field_section_built_in_attribute_path( + @user_custom_field_section, key + ) + } + end + + def move_actions(menu) + unless first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_user_custom_field_section_path(@user_custom_field_section, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, + test_selector: "user-custom-field-section-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def disabled_delete_action_item(menu) + menu.with_item(label: t("text_destroy"), disabled: true) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def edit_action_item(menu) + menu.with_item(label: t(".label_edit_section"), + tag: :button, + content_arguments: { + "data-show-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}", + "data-test-selector": "user-custom-field-section-edit" + }, + value: "") do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_user_custom_field_section_path(@user_custom_field_section), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t(:text_are_you_sure), + turbo_stream: true, + test_selector: "user-custom-field-section-delete" + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @user_custom_field_section + else + @user_custom_field_section.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @user_custom_field_section + else + @user_custom_field_section.last? + end + end + + def action_menu_item_for_custom_field_format(menu, format) + menu.with_item( + label: helpers.label_for_custom_field_format(format.name), + tag: :a, + href: new_admin_settings_user_custom_field_path( + field_format: format.name, + custom_field_section_id: @user_custom_field_section.id + ), + content_arguments: { data: { turbo: "false", + test_selector: "new-user-custom-field-in-section-button-#{format.name}" } } + ) + end + end + end +end diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.html.erb b/app/components/settings/user_custom_fields/edit_form_header_component.html.erb new file mode 100644 index 000000000000..158364331b15 --- /dev/null +++ b/app/components/settings/user_custom_fields/edit_form_header_component.html.erb @@ -0,0 +1,8 @@ +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + helpers.render_tab_header_nav(header, tabs, test_selector: :user_attribute_detail_header) + end +%> diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.rb b/app/components/settings/user_custom_fields/edit_form_header_component.rb new file mode 100644 index 000000000000..a2918f2ac7d9 --- /dev/null +++ b/app/components/settings/user_custom_fields/edit_form_header_component.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFields + class EditFormHeaderComponent < ApplicationComponent + def initialize(custom_field:, selected:) + super + @custom_field = custom_field + end + + def tabs + tabs = [ + { + name: "user_custom_field_edit", + path: edit_admin_settings_user_custom_field_path(@custom_field), + label: t(:label_details) + } + ] + + if @custom_field.hierarchical_list? + tabs << { + name: "items", + path: admin_settings_user_custom_field_items_path(@custom_field), + label: t(:label_item_plural) + } + elsif @custom_field.list? + tabs << { + name: "items", + path: list_items_admin_settings_user_custom_field_path(@custom_field), + label: t(:label_item_plural) + } + end + + tabs << + { + name: "attribute_help_text", + path: attribute_help_text_admin_settings_user_custom_field_path(@custom_field), + label: AttributeHelpText.human_attribute_name(:help_text) + } + + tabs + end + + def page_title + concat @custom_field.attribute_in_database("name") + concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(@custom_field.field_format)})" } + end + + def breadcrumbs_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_custom_fields.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(@custom_field.field_format), + @custom_field.attribute_in_database("name")) + ] + end + end + end +end diff --git a/app/components/settings/user_custom_fields/header_component.html.erb b/app/components/settings/user_custom_fields/header_component.html.erb new file mode 100644 index 000000000000..441407f59ee4 --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -0,0 +1,72 @@ +<%= component_wrapper do %> + <%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title(variant: :default) { t("settings.user_custom_fields.heading") } + header.with_description { t(".heading_description") } + header.with_breadcrumbs(breadcrumbs_items) + end + %> + + <%= + render Primer::OpenProject::SubHeader.new(collapsed_search: true) do |subheader| + subheader.with_filter_input( + name: "attribute-filter-input", + label: t(".attribute_filter.label"), + visually_hide_label: true, + placeholder: t(".attribute_filter.label"), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: "attribute-filter-clear-button", + data: { + action: "input->filter--filter-list#filterLists", + "filter--filter-list-target": "filter" + } + ) + subheader.with_action_menu( + leading_icon: :plus, + trailing_icon: :"triangle-down", + label: I18n.t(:button_add), + anchor_align: :end, + button_arguments: { + scheme: :primary, + aria: { label: I18n.t(:button_add) }, + test_selector: "user-attributes-add-menu-button" + } + ) do |menu| + menu.with_item( + label: t("settings.user_custom_fields.label_new_section"), + id: "dialog-show-user-custom-field-section-dialog", + tag: :a, + href: new_link_admin_settings_user_custom_field_sections_path, + content_arguments: { data: { controller: "async-dialog", test_selector: "add-user-custom-field-section" } } + ) + + if allow_custom_field_creation? + menu.with_sub_menu_item( + label: t("settings.user_custom_fields.label_new_attribute"), + content_arguments: { data: { test_selector: "add-user-custom-field-attribute" } } + ) do |sub_menu| + OpenProject::CustomFieldFormat.enabled_for_class_name("User") + .sort_by(&:name) + .map do |format| + sub_menu.with_item( + label: helpers.label_for_custom_field_format(format.name), + tag: :a, + href: new_admin_settings_user_custom_field_path(field_format: format.name), + content_arguments: { data: { turbo: "false", + test_selector: "new-user-custom-field-button" } } + ) do |item| + unless format.enterprise_feature_allowed? + item.with_trailing_visual_icon(icon: :"op-enterprise-addons", classes: "upsell-colored") + end + end + end + end + end + end + end + %> +<% end %> diff --git a/app/components/settings/user_custom_fields/header_component.rb b/app/components/settings/user_custom_fields/header_component.rb new file mode 100644 index 000000000000..f58a704a87b7 --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFields + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + include CustomFieldsHelper + + def initialize(allow_custom_field_creation:) + super + + @allow_custom_field_creation = allow_custom_field_creation + end + + def breadcrumbs_items + [{ href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + t("settings.user_custom_fields.heading")] + end + + def allow_custom_field_creation? + @allow_custom_field_creation + end + end + end +end diff --git a/app/components/settings/user_custom_fields/new_form_header_component.html.erb b/app/components/settings/user_custom_fields/new_form_header_component.html.erb new file mode 100644 index 000000000000..5dd415ed723c --- /dev/null +++ b/app/components/settings/user_custom_fields/new_form_header_component.html.erb @@ -0,0 +1,6 @@ +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: :normal) + end +%> diff --git a/app/components/settings/user_custom_fields/new_form_header_component.rb b/app/components/settings/user_custom_fields/new_form_header_component.rb new file mode 100644 index 000000000000..2ce044da1118 --- /dev/null +++ b/app/components/settings/user_custom_fields/new_form_header_component.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module UserCustomFields + class NewFormHeaderComponent < ApplicationComponent + def page_title + concat t("settings.user_custom_fields.new.heading") + concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(model.field_format)})" } + end + + def breadcrumb_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_custom_fields.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format), + t("settings.user_custom_fields.new.heading")) + ] + end + end + end +end diff --git a/app/components/users/form/custom_field_field_component.html.erb b/app/components/users/form/custom_field_field_component.html.erb new file mode 100644 index 000000000000..b23e7671104b --- /dev/null +++ b/app/components/users/form/custom_field_field_component.html.erb @@ -0,0 +1,5 @@ +<%= @form.fields_for_custom_fields :custom_field_values, @form.object, field_options do |cf_form| %> + <%= content_tag :div, class: css_classes do + cf_form.cf_form_field(container_class:) + end %> +<% end %> diff --git a/app/components/users/form/custom_field_field_component.rb b/app/components/users/form/custom_field_field_component.rb new file mode 100644 index 000000000000..2279a5904e4f --- /dev/null +++ b/app/components/users/form/custom_field_field_component.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module Form + class CustomFieldFieldComponent < ApplicationComponent + include ApplicationHelper + + def initialize(custom_field:, form:) + super() + @custom_field = custom_field + @form = form + end + + def field_options + { + custom_value: @form.object.custom_value_for(@custom_field), + custom_field: @custom_field + } + end + + def css_classes + ["form--field", @custom_field.attribute_name, required_class].compact + end + + def container_class + case @custom_field.field_format + when "text" then "-xxwide" + when "date" then "-xslim" + else "-middle" + end + end + + private + + def required_class + "-required" if @custom_field.is_required? && !@custom_field.boolean? + end + end + end +end diff --git a/app/components/users/form/custom_field_section_component.html.erb b/app/components/users/form/custom_field_section_component.html.erb new file mode 100644 index 000000000000..241e5c33ae26 --- /dev/null +++ b/app/components/users/form/custom_field_section_component.html.erb @@ -0,0 +1,11 @@ +
diff --git a/app/components/users/form/custom_field_section_component.rb b/app/components/users/form/custom_field_section_component.rb new file mode 100644 index 000000000000..9ab2bf9da8de --- /dev/null +++ b/app/components/users/form/custom_field_section_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module Form + class CustomFieldSectionComponent < ApplicationComponent + def initialize(section:, form:, contract:, user:) + super() + @section = section + @form = form + @contract = contract + @user = user + @visible_cfs_by_key = visible_cfs_by_key(section) + end + + def title + @section.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section") + end + + def built_in?(key) + UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + end + + def visible_custom_field(key) + @visible_cfs_by_key[key] + end + + private + + def visible_cfs_by_key(section) + section.custom_fields.visible(User.current).index_by(&:column_name) + end + end + end +end diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb index 670f889f8d48..51106f72d318 100644 --- a/app/components/users/hover_card_component.html.erb +++ b/app/components/users/hover_card_component.html.erb @@ -57,14 +57,27 @@ See COPYRIGHT and LICENSE files for more details. end end + card_sections_with_fields.flat_map(&:last).each do |custom_field| + value = @user.formatted_custom_value_for(custom_field) + + flex.with_row(data: { test_selector: "user-hover-card-custom-field" }) do + flex_layout(classes: "op-user-hover-card--custom-field") do |cf| + cf.with_column(color: :muted, mr: 2) { custom_field.name } + cf.with_column do + custom_field.multi_value? ? render_value_labels(value) : value + end + end + end + end + flex.with_row do flex_layout(classes: "op-user-hover-card--group-list") do |f| f.with_column do - render(Primer::Beta::Octicon.new(icon: :people)) + render(Primer::Beta::Octicon.new(icon: :people, color: :muted)) end f.with_column do - render(Primer::Beta::Text.new(color: :muted, data: { test_selector: "user-hover-card-groups" })) do + render(Primer::Beta::Text.new(data: { test_selector: "user-hover-card-groups" })) do group_membership_summary end end diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index 014639f18510..9cfb4fef999e 100644 --- a/app/components/users/hover_card_component.rb +++ b/app/components/users/hover_card_component.rb @@ -31,6 +31,8 @@ class Users::HoverCardComponent < ApplicationComponent include OpPrimer::ComponentHelpers + MULTI_VALUE_DISPLAY_LIMIT = 3 + def initialize(id:) super @@ -60,8 +62,31 @@ def group_membership_summary(max_length = 40) build_summary(group_links, cutoff_index) end + def card_sections_with_fields + @card_sections_with_fields ||= UserCustomFieldSection.with_filled_fields_for(@user, visible_on_user_card: true) + end + private + # Renders the values of a multi-value field as a row of Primer labels, + # capped at MULTI_VALUE_DISPLAY_LIMIT with an "and N more" overflow hint. + def render_value_labels(value) + values = Array(value) + remaining = values.size - MULTI_VALUE_DISPLAY_LIMIT + + labels = values.first(MULTI_VALUE_DISPLAY_LIMIT).map do |item| + render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } + end + + if remaining > 0 + labels << render(Primer::Beta::Text.new) do + t("custom_fields.multi_value_more", count: remaining) + end + end + + safe_join(labels, " ") + end + def linked_group_names(groups) groups.map { |group| link_to(h(group.name), show_group_path(group)) } end diff --git a/app/components/users/profile/attributes_component.html.erb b/app/components/users/profile/attributes_component.html.erb deleted file mode 100644 index 0b0c51447258..000000000000 --- a/app/components/users/profile/attributes_component.html.erb +++ /dev/null @@ -1,34 +0,0 @@ -<%= - render(Primer::OpenProject::SidePanel::Section.new) do |section| - section.with_title { t(:label_information) } - - component_wrapper do - flex_layout do |details_container| - if user_is_allowed_to_see_email - details_container.with_row(font_weight: :bold) do - User.human_attribute_name(:mail) - end - - details_container.with_row do - mail_to(escape_javascript(@user.mail), nil, encode: "javascript") - end - end - - visible_custom_fields.each do |custom_field| - details_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do - custom_field.name - end - - details_container.with_row do - value = @user.formatted_custom_value_for(custom_field) - if value.is_a?(Array) - safe_join(value, ", ") - else - value - end - end - end - end - end - end -%> diff --git a/app/components/users/profile/attributes_section_component.html.erb b/app/components/users/profile/attributes_section_component.html.erb new file mode 100644 index 000000000000..74a455e45c56 --- /dev/null +++ b/app/components/users/profile/attributes_section_component.html.erb @@ -0,0 +1,21 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new) do |section| + section.with_title { section_title } + + flex_layout do |container| + attributes.each do |attribute| + container.with_row(mt: 2, font_weight: :bold, test_selector: "user-profile-attribute") do + attribute.label + end + + container.with_row do + if attribute.multi_value? + safe_join(attribute.value.map { |item| render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } }, " ") + else + attribute.value + end + end + end + end + end +%> diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_section_component.rb similarity index 70% rename from app/components/users/profile/attributes_component.rb rename to app/components/users/profile/attributes_section_component.rb index 50f7b10ed196..59ad8b97c5b1 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_section_component.rb @@ -30,32 +30,29 @@ module Users module Profile - class AttributesComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable + # Renders a single user custom field section (its built-in attributes and custom + # fields, in attribute_order) as a side panel section. Empty sections are skipped + # by the panel via render?. + class AttributesSectionComponent < ApplicationComponent include OpPrimer::ComponentHelpers - def initialize(user:) + def initialize(section:, user:) super() + @section = section @user = user end def render? - user_is_allowed_to_see_email || @user.visible_custom_field_values.any? { it.value.present? } + attributes.any? end - def visible_custom_fields - @user - .visible_custom_field_values - .select { |cv| cv.value.present? } - .group_by(&:custom_field) - .keys - .sort_by(&:name) + def attributes + @attributes ||= SectionAttributes.for(section: @section, user: @user, current_user: User.current) end - def user_is_allowed_to_see_email - User.current == @user || User.current.allowed_globally?(:view_user_email) + def section_title + @section.name.presence || t("settings.user_custom_fields.label_untitled_section") end end end diff --git a/app/components/users/profile/section_attribute.rb b/app/components/users/profile/section_attribute.rb new file mode 100644 index 000000000000..21a1bd1b8ba7 --- /dev/null +++ b/app/components/users/profile/section_attribute.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module Profile + # A single renderable profile attribute (built-in or custom field). + # `value` is a scalar for single values, or an Array for multi-value custom fields. + class SectionAttribute + attr_reader :label, :value + + def initialize(label:, value:) + @label = label + @value = value + end + + def multi_value? + value.is_a?(Array) + end + end + end +end diff --git a/app/components/users/profile/section_attributes.rb b/app/components/users/profile/section_attributes.rb new file mode 100644 index 000000000000..3fdf76bfcafd --- /dev/null +++ b/app/components/users/profile/section_attributes.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module Profile + # Builds the ordered list of renderable SectionAttribute objects for a section, + # filtered by per-attribute visibility (mirroring API::V3::Users::UserRepresenter) + # and by value presence. Built-in keys resolve to user attributes; other keys + # resolve to the section's custom fields. + class SectionAttributes + include Redmine::I18n + + def self.for(section:, user:, current_user:) + new(section:, user:, current_user:).call + end + + def initialize(section:, user:, current_user:) + @section = section + @user = user + @current_user = current_user + end + + def call + cf_by_key = @section.custom_fields_by_key + + @section.attribute_order.filter_map do |key| + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + built_in_attribute(key) + elsif (custom_field = cf_by_key[key]) + custom_field_attribute(custom_field) + end + end + end + + private + + def built_in_attribute(key) + return unless built_in_visible?(key) + + value = built_in_value(key) + return if value.blank? + + SectionAttribute.new(label: User.human_attribute_name(key), value:) + end + + def custom_field_attribute(custom_field) + return unless custom_field.visible?(@current_user) + + value = @user.formatted_custom_value_for(custom_field) + return if value.blank? + + SectionAttribute.new(label: custom_field.name, value:) + end + + def built_in_value(key) + if key == "language" + @user.language.presence && translate_language(@user.language).first + else + @user.public_send(key) + end + end + + def built_in_visible?(key) + if key == "mail" + can_view_email? || can_manage? + else + can_manage? + end + end + + def can_manage? + @current_user.allowed_globally?(:manage_user) || + @current_user.allowed_globally?(:create_user) || + @current_user == @user + end + + def can_view_email? + @current_user.allowed_globally?(:view_user_email) + end + end + end +end diff --git a/app/components/users/profile/side_panel_component.html.erb b/app/components/users/profile/side_panel_component.html.erb index 0d426180cdef..d31800be3d9d 100644 --- a/app/components/users/profile/side_panel_component.html.erb +++ b/app/components/users/profile/side_panel_component.html.erb @@ -1,11 +1,7 @@ <%= component_wrapper do render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| - [ - Users::Profile::AttributesComponent.new(user: @user), - Users::Profile::GroupsComponent.new(user: @user), - Users::Profile::ProjectsComponent.new(user: @user) - ].each do |component| + sections.each do |component| panel.with_section(component) end end diff --git a/app/components/users/profile/side_panel_component.rb b/app/components/users/profile/side_panel_component.rb index c7accb543c84..86d29c75daea 100644 --- a/app/components/users/profile/side_panel_component.rb +++ b/app/components/users/profile/side_panel_component.rb @@ -40,6 +40,24 @@ def initialize(user:) @user = user end + + # Each entry becomes its own SidePanel section, so headings and separators + # are provided consistently by the panel rather than drawn by hand. + def sections + [ + *attributes_section_components, + Users::Profile::GroupsComponent.new(user: @user), + Users::Profile::ProjectsComponent.new(user: @user) + ] + end + + private + + def attributes_section_components + UserCustomFieldSection.includes(:custom_fields).map do |section| + Users::Profile::AttributesSectionComponent.new(section:, user: @user) + end + end end end end diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index e8d11e9d9ed5..4e11bb2c8a5a 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -53,6 +53,7 @@ class BaseContract < ::ModelContract attribute :possible_values attribute :regexp attribute :searchable + attribute :visible_on_user_card attribute :type def validate_non_true_for_some_formats diff --git a/app/contracts/user_custom_field_sections/base_contract.rb b/app/contracts/user_custom_field_sections/base_contract.rb new file mode 100644 index 000000000000..e136f67afb28 --- /dev/null +++ b/app/contracts/user_custom_field_sections/base_contract.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :name + attribute :position + attribute :type + end +end diff --git a/app/contracts/user_custom_field_sections/create_contract.rb b/app/contracts/user_custom_field_sections/create_contract.rb new file mode 100644 index 000000000000..ed0cd86c3fc6 --- /dev/null +++ b/app/contracts/user_custom_field_sections/create_contract.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class CreateContract < BaseContract + end +end diff --git a/app/contracts/user_custom_field_sections/delete_contract.rb b/app/contracts/user_custom_field_sections/delete_contract.rb new file mode 100644 index 000000000000..4ed0b2a9b816 --- /dev/null +++ b/app/contracts/user_custom_field_sections/delete_contract.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class DeleteContract < BaseContract + validate :section_not_in_use + + def section_not_in_use + if model.custom_fields.exists? + errors.add(:base, :in_use) + end + end + end +end diff --git a/app/contracts/user_custom_field_sections/update_contract.rb b/app/contracts/user_custom_field_sections/update_contract.rb new file mode 100644 index 000000000000..504945fc415b --- /dev/null +++ b/app/contracts/user_custom_field_sections/update_contract.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class UpdateContract < BaseContract + end +end diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb index 3fb520adfa63..3b9f1f1ae167 100644 --- a/app/controllers/admin/settings/project_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -42,6 +42,7 @@ def create ) if call.success? + close_dialog_via_turbo_stream("##{Settings::ProjectCustomFieldSections::NewSectionDialogComponent::MODAL_ID}") update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else @@ -57,6 +58,7 @@ def update ) if call.success? + close_dialog_via_turbo_stream("#project-custom-field-section-dialog#{@project_custom_field_section.id}") update_section_via_turbo_stream(project_custom_field_section: call.result) else update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) @@ -72,7 +74,7 @@ def destroy update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -86,7 +88,7 @@ def move if call.success? update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -101,7 +103,7 @@ def drop update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams end @@ -112,6 +114,14 @@ def new_link private + # Show a danger toast with the action's hint (resolved relative to the + # controller/action), appending the service's error detail (e.g. why a + # non-empty section cannot be deleted) when present. + def render_section_error_via_turbo_stream(call) + message = [t(".error"), call.message].compact_blank.join(" ") + render_error_flash_message_via_turbo_stream(message:) + end + def set_project_custom_field_section @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 8b88b00eb250..48657fc76997 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -143,8 +143,8 @@ def unlink end def move - result = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( - move_to: params[:move_to]&.to_sym + result = CustomFields::MoveService.new(user: current_user, custom_field: @custom_field).call( + move_to: params.expect(:move_to) ) if result.success? @@ -159,7 +159,7 @@ def move end def drop - result = ::ProjectCustomFields::DropService.new(user: current_user, project_custom_field: @custom_field).call( + result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call( target_id: params[:target_id], position: params[:position] ) @@ -262,7 +262,7 @@ def find_custom_field_projects_to_link end def find_custom_field - @custom_field = ProjectCustomField.find(params[:id]) + @custom_field = ProjectCustomField.find(params.expect(:id)) end def drop_success_streams(call) diff --git a/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb b/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb new file mode 100644 index 000000000000..a2c0ed91c727 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module Settings + module UserCustomFieldSections + class BuiltInAttributesController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::UserCustomFields::ComponentStreams + + menu_item :user_custom_fields_settings + + before_action :set_section + before_action :set_attribute_key + + def move + @section.move_in_order(@key, params.expect(:move_to)&.to_sym) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + respond_with_turbo_streams + end + + def drop # rubocop:disable Metrics/AbcSize + new_section_id = params[:target_id]&.to_i + return head :bad_request if new_section_id.nil? + + if @section.id == new_section_id + @section.add_to_order(@key, position: params[:position]&.to_i) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + else + new_section = UserCustomFieldSection.find(new_section_id) + @section.remove_from_order(@key) + new_section.add_to_order(@key, position: params[:position]&.to_i) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + update_section_via_turbo_stream(user_custom_field_section: new_section.reload) + end + + respond_with_turbo_streams + end + + private + + def set_section + @section = UserCustomFieldSection.find(params.expect(:user_custom_field_section_id)) + end + + def set_attribute_key + @key = params.expect(:key) + head :unprocessable_entity unless UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(@key) + end + end + end + end +end diff --git a/app/controllers/admin/settings/user_custom_field_sections_controller.rb b/app/controllers/admin/settings/user_custom_field_sections_controller.rb new file mode 100644 index 000000000000..e057b0167c90 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections_controller.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin::Settings + class UserCustomFieldSectionsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::UserCustomFields::ComponentStreams + + before_action :set_user_custom_field_section, only: %i[update move drop destroy] + + def create + call = ::UserCustomFieldSections::CreateService.new(user: current_user).call( + user_custom_field_section_params.merge(position: 1) + ) + + if call.success? + close_dialog_via_turbo_stream("##{Settings::UserCustomFieldSections::NewSectionDialogComponent::MODAL_ID}") + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + update_section_dialog_body_form_via_turbo_stream(user_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def update + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + user_custom_field_section_params + ) + + if call.success? + close_dialog_via_turbo_stream("#user-custom-field-section-dialog#{@user_custom_field_section.id}") + update_section_via_turbo_stream(user_custom_field_section: call.result) + else + update_section_dialog_body_form_via_turbo_stream(user_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def destroy + call = ::UserCustomFieldSections::DeleteService.new(user: current_user, model: @user_custom_field_section).call + + if call.success? + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + render_section_error_via_turbo_stream(call) + end + + respond_with_turbo_streams + end + + def move + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + move_to: params.expect(:move_to)&.to_sym + ) + + if call.success? + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + render_section_error_via_turbo_stream(call) + end + + respond_with_turbo_streams + end + + def drop + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + position: params[:position].to_i + ) + + if call.success? + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + render_section_error_via_turbo_stream(call) + end + + respond_with_turbo_streams + end + + def new_link + respond_with_dialog Settings::UserCustomFieldSections::NewSectionDialogComponent.new + end + + private + + # Show a danger toast with the action's hint (resolved relative to the + # controller/action), appending the service's error detail (e.g. why a + # non-empty section cannot be deleted) when present. + def render_section_error_via_turbo_stream(call) + message = [t(".error"), call.message].compact_blank.join(" ") + render_error_flash_message_via_turbo_stream(message:) + end + + def set_user_custom_field_section + @user_custom_field_section = UserCustomFieldSection.find(params.expect(:id)) + end + + def allow_custom_field_creation? + UserCustomFieldSection.any? + end + + def user_custom_field_section_params + params.expect(user_custom_field_section: [:name]) + end + end +end diff --git a/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb new file mode 100644 index 000000000000..f02af815110c --- /dev/null +++ b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module Settings + module UserCustomFields + module Hierarchy + class ItemsController < Admin::CustomFields::Hierarchy::ItemsBaseController + menu_item :user_custom_fields_settings + + private + + def find_custom_field + @custom_field = CustomField.hierarchy_root_and_children.find(params.expect(:user_custom_field_id)) + end + end + end + end + end +end diff --git a/app/controllers/admin/settings/user_custom_fields_controller.rb b/app/controllers/admin/settings/user_custom_fields_controller.rb new file mode 100644 index 000000000000..580cfe6a2af6 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_fields_controller.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin::Settings + class UserCustomFieldsController < ::Admin::SettingsController + include CustomFields::SharedActions + include CustomFields::AttributeHelpTextActions + include OpTurbo::ComponentStream + include FlashMessagesOutputSafetyHelper + include Admin::Settings::UserCustomFields::ComponentStreams + + menu_item :user_custom_fields_settings + + # rubocop:disable Rails/LexicallyScopedActionFilter + before_action :set_sections, only: %i[show index edit update move drop] + before_action :find_custom_field, + only: %i(show edit update destroy delete_option reorder_alphabetical + move drop attribute_help_text update_attribute_help_text list_items) + before_action :prepare_custom_option_position, only: %i(update create) + before_action :find_custom_option, only: :delete_option + before_action :find_or_initialize_attribute_help_text, only: %i[attribute_help_text update_attribute_help_text] + # rubocop:enable Rails/LexicallyScopedActionFilter + + def index + @allow_custom_field_creation = @user_custom_field_sections.any? + + respond_to :html + end + + def show + render :edit + end + + def new + @custom_field = UserCustomField.new(custom_field_section_id: params[:custom_field_section_id], + field_format: params[:field_format]) + + respond_to :html + end + + def edit; end + + def list_items; end + + def move + result = CustomFields::MoveService.new(user: current_user, custom_field: @custom_field).call( + move_to: params.expect(:move_to) + ) + + if result.success? + update_sections_via_turbo_stream(user_custom_field_sections: @user_custom_field_sections) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def drop + result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call( + target_id: params[:target_id], + position: params[:position] + ) + + if result.success? + drop_success_streams(result) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def destroy + result = CustomFields::DeleteService.new(user: current_user, model: @custom_field).call + + if result.success? + update_section_via_turbo_stream(user_custom_field_section: @custom_field.user_custom_field_section.reload) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def attribute_help_text + render_attribute_help_text_form + end + + def update_attribute_help_text + update_help_text + end + + private + + def set_sections + @user_custom_field_sections = UserCustomFieldSection.includes(:custom_fields).all + end + + def find_custom_field + @custom_field = UserCustomField.find(params.expect(:id)) + end + + def drop_success_streams(call) + update_section_via_turbo_stream(user_custom_field_section: call.result[:current_section]) + if call.result[:section_changed] + update_section_via_turbo_stream(user_custom_field_section: call.result[:old_section]) + end + end + + def show_path + attribute_help_text_admin_settings_user_custom_field_path(@custom_field) + end + + def render_attribute_help_text_form(status: :ok) + render "custom_fields/attribute_help_texts/show_user", status: + end + end +end diff --git a/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb new file mode 100644 index 000000000000..180d1780410a --- /dev/null +++ b/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module Settings + module UserCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_header_via_turbo_stream(allow_custom_field_creation:) + update_via_turbo_stream( + component: ::Settings::UserCustomFields::HeaderComponent.new( + allow_custom_field_creation: + ) + ) + end + + def update_section_via_turbo_stream(user_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::ShowComponent.new( + user_custom_field_section: + ) + ) + end + + def update_section_dialog_body_form_via_turbo_stream(user_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::DialogBodyFormComponent.new( + user_custom_field_section: + ) + ) + end + + def update_sections_via_turbo_stream(user_custom_field_sections:) + replace_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::IndexComponent.new( + user_custom_field_sections: + ) + ) + end + end + end + end + end +end diff --git a/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb b/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb index f5d036f86610..ce5af9f72733 100644 --- a/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb +++ b/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb @@ -71,6 +71,8 @@ def attribute_help_text_class_for_custom_field AttributeHelpText::Project when WorkPackageCustomField AttributeHelpText::WorkPackage + when UserCustomField + AttributeHelpText::User else raise ArgumentError, "Unsupported custom field type: #{@custom_field.class}" end diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb index eba9a48284ec..2f15cff427e7 100644 --- a/app/controllers/concerns/custom_fields/shared_actions.rb +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -34,24 +34,33 @@ module SharedActions included do def index_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" admin_settings_project_custom_fields_path(**params) + when "UserCustomField" + admin_settings_user_custom_fields_path(**params) else custom_fields_path(**params) end end def edit_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" admin_settings_project_custom_field_path(**params) + when "UserCustomField" + edit_admin_settings_user_custom_field_path(**params) else edit_custom_field_path(**params) end end def list_item_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" list_items_admin_settings_project_custom_field_path(**params) + when "UserCustomField" + list_items_admin_settings_user_custom_field_path(**params) else list_items_custom_field_path(**params) end diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index 70bc7741c7d3..007f530ed82c 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -48,7 +48,7 @@ class CustomFieldsController < ApplicationController def index # loading wp cfs exclicity to allow for eager loading @custom_fields_by_type = CustomField - .where.not(type: ["WorkPackageCustomField", "ProjectCustomField"]) + .where.not(type: ["WorkPackageCustomField", "ProjectCustomField", "UserCustomField"]) .group_by { |f| f.class.name } @custom_fields_by_type["WorkPackageCustomField"] = WorkPackageCustomField.includes(:types).all @@ -89,8 +89,8 @@ def find_custom_field end def check_custom_field - # ProjectCustomFields now managed in a different UI - if @custom_field.nil? || @custom_field.type == "ProjectCustomField" + # ProjectCustomFields and UserCustomFields now managed in a different UI + if @custom_field.nil? || @custom_field.type.in?(%w[ProjectCustomField UserCustomField]) flash[:error] = "Invalid CF type" redirect_to action: :index end diff --git a/app/controllers/projects/creation_wizard_controller.rb b/app/controllers/projects/creation_wizard_controller.rb index f444065596b4..fb76b36dc36d 100644 --- a/app/controllers/projects/creation_wizard_controller.rb +++ b/app/controllers/projects/creation_wizard_controller.rb @@ -101,10 +101,8 @@ def load_sections_and_fields .where(creation_wizard: true) .select(:custom_field_id) - @custom_fields_by_section = @project - .available_custom_fields - .where(id: enabled_in_wizard_ids) - .group_by(&:project_custom_field_section) + scoped_fields = @project.available_custom_fields.where(id: enabled_in_wizard_ids) + @custom_fields_by_section = ProjectCustomFieldSection.grouped_in_order(scoped_fields).to_h end def find_current_section diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 4150b2da595d..162553b4cb1b 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -91,7 +91,7 @@ def eager_load_project_custom_field_data .includes(:custom_fields) .merge(ProjectCustomField.visible) .group(:id, "custom_fields.id") - .order(:position, :position_in_custom_field_section) + .order(:position) end def set_project_custom_field_section diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 58d9a81828be..f19a35f6f8e0 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -54,8 +54,8 @@ class DetailsForm < ApplicationForm label: I18n.t("activerecord.attributes.project_custom_field.custom_field_section"), required: true ) do |list| - ProjectCustomFieldSection.find_each do |cs| - list.option(value: cs.id, label: cs.name) + section_class_for_model.all.each do |cs| # rubocop:disable Rails/FindEach -- ordered by default_scope; find_each would override it + list.option(value: cs.id, label: cs.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section")) end end end @@ -199,6 +199,14 @@ class DetailsForm < ApplicationForm ) end + if show_on_user_card_field? + details_form.check_box( + name: :visible_on_user_card, + label: label(:visible_on_user_card), + caption: instructions(:visible_on_user_card) + ) + end + details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :default) end @@ -217,7 +225,11 @@ def instructions(field) end def show_section_field? - model.is_a?(ProjectCustomField) + model.is_a?(ProjectCustomField) || model.is_a?(UserCustomField) + end + + def section_class_for_model + model.is_a?(UserCustomField) ? UserCustomFieldSection : ProjectCustomFieldSection end def show_default_bool_field? @@ -285,6 +297,10 @@ def show_editable_field? model.is_a?(UserCustomField) end + def show_on_user_card_field? + model.is_a?(UserCustomField) + end + def formula_suggestions operators = CustomField::CalculatedValue::MATH_OPERATORS_FOR_FORMULA # Hide % from the suggestions as it can be used as either modulo or percentage. diff --git a/app/forms/project_custom_field_sections/name_form.rb b/app/forms/project_custom_field_sections/name_form.rb index 54d94661962c..2486c805e348 100644 --- a/app/forms/project_custom_field_sections/name_form.rb +++ b/app/forms/project_custom_field_sections/name_form.rb @@ -35,7 +35,8 @@ class ProjectCustomFieldSections::NameForm < ApplicationForm placeholder: ProjectCustomFieldSection.human_attribute_name(:name), label: ProjectCustomFieldSection.human_attribute_name(:name), required: true, - autofocus: true + autofocus: true, + autocomplete: "off" ) end end diff --git a/app/forms/user_custom_field_sections/name_form.rb b/app/forms/user_custom_field_sections/name_form.rb new file mode 100644 index 000000000000..f1bb32ec6367 --- /dev/null +++ b/app/forms/user_custom_field_sections/name_form.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserCustomFieldSections::NameForm < ApplicationForm + form do |user_custom_field_section_form| + user_custom_field_section_form.text_field( + name: :name, + placeholder: UserCustomFieldSection.human_attribute_name(:name), + label: UserCustomFieldSection.human_attribute_name(:name), + required: true, + autofocus: true, + autocomplete: "off" + ) + end +end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index b5f68de591da..0eca25e194b5 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -49,12 +49,6 @@ def custom_fields_tabs path: custom_fields_path(tab: :VersionCustomField), label: :label_version_plural }, - { - name: "UserCustomField", - partial: "custom_fields/tab", - path: custom_fields_path(tab: :UserCustomField), - label: :label_user_plural - }, { name: "GroupCustomField", partial: "custom_fields/tab", diff --git a/app/models/attribute_help_text/user.rb b/app/models/attribute_help_text/user.rb new file mode 100644 index 000000000000..208c354ad550 --- /dev/null +++ b/app/models/attribute_help_text/user.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class AttributeHelpText::User < AttributeHelpText + def self.available_attributes + attributes = {} + + UserCustomField.find_each do |field| + attributes[field.attribute_name] = field.name + end + + attributes + end + + validates :attribute_name, inclusion: { in: ->(*) { available_attributes.keys } } + + def type_caption + ::User.model_name.human + end + + def self.visible_condition(_user) + ::AttributeHelpText.where(attribute_name: available_attributes.keys) + end +end diff --git a/app/models/custom_field/sectionable.rb b/app/models/custom_field/sectionable.rb new file mode 100644 index 000000000000..7f30e143d1c2 --- /dev/null +++ b/app/models/custom_field/sectionable.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Shared behaviour for custom fields that belong to a CustomFieldSection and +# maintain their position within that section via attribute_order. +# Include in UserCustomField and ProjectCustomField. +module CustomField::Sectionable + extend ActiveSupport::Concern + + included do + belongs_to :custom_field_section, class_name: "CustomFieldSection", + inverse_of: false + + validates :custom_field_section_id, presence: true + + after_create_commit :add_to_section_order + after_destroy_commit :remove_from_section_order + end + + def add_to_section_order + custom_field_section.add_to_order(column_name) + end + + def remove_from_section_order + section = custom_field_section + section.remove_from_order(column_name) unless section.nil? || section.frozen? + end +end diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb index 84f9db4f3d1b..854981534cca 100644 --- a/app/models/custom_field_section.rb +++ b/app/models/custom_field_section.rb @@ -48,4 +48,47 @@ def shown_in_overview_sidebar? def shown_in_overview_main_area? overview == OVERVIEW__MAIN_AREA_KEY end + + # Append key to the end of the ordered list (or at a specific 1-indexed position). + # Idempotent: removes any existing occurrence first. + def add_to_order(key, position: nil) + keys = attribute_order.reject { |k| k == key } + if position && position > 0 + keys.insert(position - 1, key) + else + keys << key + end + update_column(:attribute_order, keys) + end + + def remove_from_order(key) + update_column(:attribute_order, attribute_order.reject { |k| k == key }) + end + + def custom_fields_by_key + custom_fields.index_by(&:column_name) + end + + def custom_fields_in_order + cf_by_key = custom_fields_by_key + attribute_order.filter_map { |key| cf_by_key[key] } + end + + # move_to: :highest | :higher | :lower | :lowest + def move_in_order(key, move_to) + idx = attribute_order.index(key) + return unless idx + + keys = attribute_order.dup + keys.delete_at(idx) + new_idx = case move_to.to_sym + when :highest then 0 + when :lowest then keys.size + when :higher then [idx - 1, 0].max + when :lower then [idx + 1, keys.size].min + else return + end + keys.insert(new_idx, key) + update_column(:attribute_order, keys) + end end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 6934800f5192..2ea259685fb1 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -532,6 +532,7 @@ def self.permitted_attributes :custom_field_section_id, :allow_non_open_versions, :has_comment, + :visible_on_user_card, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } ], diff --git a/app/models/project/pdf_export/project_initiation.rb b/app/models/project/pdf_export/project_initiation.rb index cf39ae9ad79b..76cbc488945f 100644 --- a/app/models/project/pdf_export/project_initiation.rb +++ b/app/models/project/pdf_export/project_initiation.rb @@ -183,9 +183,10 @@ def enabled_in_wizard_ids .select(:custom_field_id) end - def collect_custom_fields_data + def collect_custom_fields_data # rubocop:disable Metrics/AbcSize project.available_custom_fields .where(id: enabled_in_wizard_ids) + .order("custom_fields.id") .group_by(&:project_custom_field_section) .map do |section, custom_fields| { diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 0017569b3718..00d4a0f1d649 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -29,18 +29,16 @@ #++ class ProjectCustomField < CustomField + include CustomField::Sectionable + belongs_to :project_custom_field_section, class_name: "ProjectCustomFieldSection", foreign_key: :custom_field_section_id, inverse_of: :custom_fields has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :project_custom_field has_many :projects, through: :project_custom_field_project_mappings - acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] - after_save :activate_required_field_in_all_projects, if: :is_for_all? - validates :custom_field_section_id, presence: true - # Relevant for user fields to allow membership assignment has_one :custom_fields_role, foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :custom_field has_one :role, through: :custom_fields_role diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index a0f019139557..e2f6c9a2f879 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -29,9 +29,33 @@ #++ class ProjectCustomFieldSection < CustomFieldSection - has_many :custom_fields, -> { order(position_in_custom_field_section: :asc) }, + has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy, foreign_key: :custom_field_section_id, inverse_of: :project_custom_field_section + + # Returns [[section, [ordered_cfs]], ...] for the given project's available, + # visible custom fields. Sections are ordered by position, fields within each + # section by attribute_order. Two SQL queries: one for the CFs, one for the sections. + def self.with_available_fields_for(project) + grouped_in_order(project.available_custom_fields) + end + + # Groups the given custom fields by their section, with sections ordered by + # position and the fields within each section ordered by attribute_order. + # Sections that end up with no ordered field are dropped. + # Returns [[section, [ordered_cfs]], ...]. Two SQL queries: the given relation + # is materialised once, then the relevant sections are loaded. + def self.grouped_in_order(custom_fields) + cfs_by_section_id = custom_fields.group_by(&:custom_field_section_id) + + where(id: cfs_by_section_id.keys) + .order(:position) + .filter_map do |section| + cf_by_key = cfs_by_section_id[section.id].index_by(&:column_name) + ordered = section.attribute_order.filter_map { |key| cf_by_key[key] } + [section, ordered] if ordered.any? + end + end end diff --git a/app/models/projects/custom_fields.rb b/app/models/projects/custom_fields.rb index f7fa007a7a0d..ebea9bda2f1d 100644 --- a/app/models/projects/custom_fields.rb +++ b/app/models/projects/custom_fields.rb @@ -63,7 +63,7 @@ def all_available_custom_fields RequestStore.fetch("#{self.class}#all_available_custom_fields") do ProjectCustomField .includes(:project_custom_field_section) - .order("custom_field_sections.position", :position_in_custom_field_section) + .order("custom_field_sections.position") end end diff --git a/app/models/user_custom_field.rb b/app/models/user_custom_field.rb index f9cc648a8eda..df296a39a792 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -29,6 +29,11 @@ #++ class UserCustomField < CustomField + include CustomField::Sectionable + + belongs_to :user_custom_field_section, class_name: "UserCustomFieldSection", foreign_key: :custom_field_section_id, + inverse_of: :custom_fields + scopes :visible def type_name diff --git a/app/models/user_custom_field_section.rb b/app/models/user_custom_field_section.rb new file mode 100644 index 000000000000..3227c021968f --- /dev/null +++ b/app/models/user_custom_field_section.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserCustomFieldSection < CustomFieldSection + BUILT_IN_ATTRIBUTES = %w[login firstname lastname mail language].freeze + + has_many :custom_fields, + class_name: "UserCustomField", + dependent: :restrict_with_exception, + foreign_key: :custom_field_section_id, + inverse_of: :user_custom_field_section + + # Returns sections that contain at least one of the given custom field IDs, + # ordered by section position. Field ordering within each section is driven + # by attribute_order in Ruby. + scope :with_custom_fields, ->(ids) { + joins(:custom_fields) + .where(custom_fields: { id: ids }) + .includes(:custom_fields) + .order("custom_field_sections.position") + } + + # Returns [[section, [ordered_cfs]], ...] for the given user's filled, visible + # custom fields. Pass visible_on_user_card: true to restrict to hover-card fields. + # Two SQL queries: one for sections, one for the relevant CFs (both use filled_cf_ids + # as a subquery so no values are materialised into Ruby memory). + def self.with_filled_fields_for(user, visible_on_user_card: nil) # rubocop:disable Metrics/AbcSize + cf_scope = UserCustomField.visible(User.current) + cf_scope = cf_scope.where(visible_on_user_card: true) if visible_on_user_card + + filled_cf_ids = user.custom_values + .where(custom_field: cf_scope) + .where.not(value: [nil, ""]) + .select(:custom_field_id) + + sections = joins(:custom_fields) + .where(custom_fields: { id: filled_cf_ids }) + .distinct + .order(:position) + + cfs_by_section = cf_scope.where(id: filled_cf_ids) + .group_by(&:custom_field_section_id) + + sections.filter_map do |section| + cf_by_key = (cfs_by_section[section.id] || []).index_by(&:column_name) + ordered = section.attribute_order.filter_map { |key| cf_by_key[key] } + [section, ordered] if ordered.any? + end + end + + def untitled? + name.blank? + end +end diff --git a/app/seeders/basic_data/user_custom_field_section_seeder.rb b/app/seeders/basic_data/user_custom_field_section_seeder.rb new file mode 100644 index 000000000000..ad6f40df824c --- /dev/null +++ b/app/seeders/basic_data/user_custom_field_section_seeder.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module BasicData + class UserCustomFieldSectionSeeder < Seeder + def seed_data! + # The default section is intentionally untitled — it renders the I18n fallback + # label in the UI. The name validation is bypassed to match the migration that + # creates the same section for existing installations. + section = UserCustomFieldSection.new( + position: 1, + attribute_order: UserCustomFieldSection::BUILT_IN_ATTRIBUTES + ) + section.save!(validate: false) + end + + def applicable? + UserCustomFieldSection.none? + end + + def not_applicable_message + "Skipping user custom field section as there are already some configured" + end + end +end diff --git a/app/seeders/standard/basic_data_seeder.rb b/app/seeders/standard/basic_data_seeder.rb index 4f81b3066af0..824643889986 100644 --- a/app/seeders/standard/basic_data_seeder.rb +++ b/app/seeders/standard/basic_data_seeder.rb @@ -47,7 +47,8 @@ def data_seeder_classes ::BasicData::WorkflowSeeder, ::BasicData::PrioritySeeder, ::BasicData::SettingSeeder, - ::BasicData::ProjectCustomFieldSectionSeeder + ::BasicData::ProjectCustomFieldSectionSeeder, + ::BasicData::UserCustomFieldSectionSeeder ] end end diff --git a/app/services/project_custom_fields/drop_service.rb b/app/services/custom_fields/drop_service.rb similarity index 59% rename from app/services/project_custom_fields/drop_service.rb rename to app/services/custom_fields/drop_service.rb index c3501e7539af..ddba3925c492 100644 --- a/app/services/project_custom_fields/drop_service.rb +++ b/app/services/custom_fields/drop_service.rb @@ -28,18 +28,17 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectCustomFields +module CustomFields class DropService < ::BaseServices::BaseCallable - def initialize(user:, project_custom_field:) + def initialize(user:, custom_field:) super() @user = user - @project_custom_field = project_custom_field + @custom_field = custom_field end def perform service_call = validate_permissions service_call = perform_drop(service_call, params) if service_call.success? - service_call end @@ -52,44 +51,32 @@ def validate_permissions end def perform_drop(service_call, params) - begin - section_changed, current_section, old_section = check_and_update_section_if_changed(params) - update_position(params[:position]&.to_i) - - service_call.success = true - service_call.result = { section_changed:, current_section:, old_section: } - rescue StandardError => e - service_call.success = false - service_call.errors = e.message - end + section_changed, current_section, old_section = move_to_target_section(params) + current_section.add_to_order(@custom_field.column_name, position: params[:position]&.to_i) + service_call.success = true + service_call.result = { section_changed:, current_section: current_section.reload, old_section: } + service_call + rescue StandardError => e + service_call.success = false + service_call.errors = e.message service_call end private - def check_and_update_section_if_changed(params) - current_section = @project_custom_field.project_custom_field_section + def move_to_target_section(params) + current_section = @custom_field.custom_field_section new_section_id = params[:target_id]&.to_i - if current_section.id != new_section_id - old_section = current_section - current_section = update_section(new_section_id) - return [true, current_section, old_section] - end - - [false, current_section, nil] - end + return [false, current_section, nil] if current_section.id == new_section_id - def update_section(new_section_id) - current_section = ProjectCustomFieldSection.find(new_section_id) - @project_custom_field.remove_from_list - @project_custom_field.update(project_custom_field_section: current_section) - current_section - end + old_section = current_section + current_section = CustomFieldSection.find(new_section_id) + old_section.remove_from_order(@custom_field.column_name) + @custom_field.update!(custom_field_section_id: current_section.id) - def update_position(new_position) - @project_custom_field.insert_at(new_position) + [true, current_section, old_section.reload] end end end diff --git a/app/services/custom_fields/move_service.rb b/app/services/custom_fields/move_service.rb new file mode 100644 index 000000000000..510fc5d736d4 --- /dev/null +++ b/app/services/custom_fields/move_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module CustomFields + class MoveService < ::BaseServices::BaseCallable + def initialize(user:, custom_field:) + super() + @user = user + @custom_field = custom_field + end + + def perform + return ServiceResult.failure(errors: { base: :error_unauthorized }) unless @user.admin? + + section = @custom_field.custom_field_section + section.move_in_order(@custom_field.column_name, params[:move_to]&.to_sym) + ServiceResult.success(result: section) + rescue StandardError => e + ServiceResult.failure(errors: e.message) + end + end +end diff --git a/app/services/user_custom_field_sections/create_service.rb b/app/services/user_custom_field_sections/create_service.rb new file mode 100644 index 000000000000..ff08f9302b43 --- /dev/null +++ b/app/services/user_custom_field_sections/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/user_custom_field_sections/delete_service.rb b/app/services/user_custom_field_sections/delete_service.rb new file mode 100644 index 000000000000..8a09d3b05409 --- /dev/null +++ b/app/services/user_custom_field_sections/delete_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/user_custom_field_sections/set_attributes_service.rb b/app/services/user_custom_field_sections/set_attributes_service.rb new file mode 100644 index 000000000000..2c5e6634d931 --- /dev/null +++ b/app/services/user_custom_field_sections/set_attributes_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/user_custom_field_sections/update_service.rb b/app/services/user_custom_field_sections/update_service.rb new file mode 100644 index 000000000000..a41e70a0d9a1 --- /dev/null +++ b/app/services/user_custom_field_sections/update_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserCustomFieldSections + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/views/admin/settings/user_custom_fields/edit.html.erb b/app/views/admin/settings/user_custom_fields/edit.html.erb new file mode 100644 index 000000000000..5ac672e582ee --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/edit.html.erb @@ -0,0 +1,41 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :user_custom_field_edit + ) + ) +%> + +<%= render CustomFields::DetailsComponent.new(@custom_field) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb new file mode 100644 index 000000000000..a493d877c5ed --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemComponent.new(item: @active_item, custom_field: @custom_field, show_edit_form: true)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb new file mode 100644 index 000000000000..5442cb0d37bc --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb new file mode 100644 index 000000000000..ccf51d52126a --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item, new_item: @new_item)) %> diff --git a/app/views/admin/settings/user_custom_fields/index.html.erb b/app/views/admin/settings/user_custom_fields/index.html.erb new file mode 100644 index 000000000000..8f69bafa4a96 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/index.html.erb @@ -0,0 +1,38 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading") %> + +Hello,
A new project has been created: projectValue:name
Thank you
+ project_custom_field_sections: + destroy: + error: "The section could not be deleted." + drop: + error: "The section could not be reordered." + move: + error: "The section could not be reordered." + user_custom_field_sections: + destroy: + error: "The section could not be deleted." + drop: + error: "The section could not be reordered." + move: + error: "The section could not be reordered." work_packages_identifier: page_header: description: Choose between classic numerical work package IDs or semantic project-specific ones that prepend the project identifier to the work package ID. @@ -711,6 +726,8 @@ en: gain_role: Will gain role ‘%{new_role}’ lose_role: Will lose role ‘%{old_role}’ no_change: No changes + multi_value_more: + other: "and %{count} more" text_add_new_custom_field: > To add new custom fields to a project you first need to create them before you can add them to this project. @@ -755,6 +772,8 @@ en: project: "0 means no restriction" has_comment: project: "Allows the user to add a comment related to the project attribute when selecting the value in the project overview." + visible_on_user_card: + all: "When checked, this field's value is shown in the user information card when hovering over a user across the application." tab: no_results_title_text: There are currently no custom fields. @@ -1798,18 +1817,6 @@ en: activerecord: attributes: - work_package_semantic_alias: - identifier: "Identifier" - work_package: "Work package" - jira_import: - projects: "Projects" - "import/jira": - name: "Jira instance name" - url: "Jira instance URL" - personal_access_token: "Personal access token" - "import/jira_open_project_reference": - jira: "Jira" - jira_import: "Jira Migrator" announcements: show_until: "Display until" attachment: @@ -1855,6 +1862,7 @@ en: possible_values: "Possible values" regexp: "Regular expression" searchable: "Searchable" + visible_on_user_card: "Show on user card" admin_only: "Admin-only" has_comment: "Add a comment text field" custom_value: @@ -1889,6 +1897,23 @@ en: row_count: "Number of rows" column_count: "Number of columns" widgets: "Widgets" + group: + group_users: "Group users" + identity_url: "Identity URL" + organizational_unit: "Organizational unit" + parent: "Parent group" + group_detail: + organizational_unit: "Organizational unit" + parent: "Parent group" + "import/jira": + name: "Jira instance name" + personal_access_token: "Personal access token" + url: "Jira instance URL" + "import/jira_open_project_reference": + jira: "Jira" + jira_import: "Jira Migrator" + jira_import: + projects: "Projects" journal: notes: "Notes" cause_type: "Cause type" @@ -1917,6 +1942,21 @@ en: resource: "Resource" oauth_client: client: "Client ID" + ordered_persisted_query_entity: + entity: "Entity" + persisted_query: "Persisted query" + position: "Position" + persisted_query: + filters: "Filters" + name: "Name" + orders: "Orders" + selects: "Selects" + views: "Views" + persisted_view: + name: "Name" + parent: "Parent view" + public: "Public" + query: "Query" project: active_value: true: "unarchived" @@ -2063,14 +2103,8 @@ en: new_password: "New password" password_confirmation: "Confirmation" consented_at: "Consented at" - group: - identity_url: "Identity URL" - parent: "Parent group" - organizational_unit: "Organizational unit" - group_users: "Group users" - group_detail: - parent: "Parent group" - organizational_unit: "Organizational unit" + user_custom_field: + custom_field_section: Section user_preference: header_look_and_feel: "Look and feel" header_alerts: "Alerts" @@ -2186,7 +2220,9 @@ en: query: "Query" parent: "Parent view" public: "Public" - + work_package_semantic_alias: + identifier: "Identifier" + work_package: "Work package" errors: messages: accepted: "must be accepted." @@ -5675,6 +5711,7 @@ en: passwords: "Passwords" project_attributes: heading: "Project attributes" + label_add_attribute: "Add a project attribute" label_for_all_projects: "All projects" label_new_attribute: "Project attribute" label_new_section: "Section" @@ -5773,8 +5810,30 @@ en: session: "Session" user: default_preferences: "Default preferences" - display_format: "Display format" deletion: "Deletion" + display_format: "Display format" + user_custom_field_sections: + built_in_attribute_row_component: + label_built_in: "Built-in" + label_built_in_attribute_actions: "Built-in attribute actions" + custom_field_row_component: + label_user_custom_field_actions: "User attribute actions" + show_component: + label_add_attribute: "Add a user attribute" + label_edit_section: "Edit title" + label_no_user_custom_fields: "No user attributes defined in this section" + label_section_actions: "Section actions" + user_custom_fields: + header_component: + attribute_filter: + label: "Filter by attribute name…" + heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." + heading: "User attributes" + label_new_attribute: "User attribute" + label_new_section: "Section" + label_untitled_section: "Untitled section" + new: + heading: "New attribute" working_days: section_work_week: "Work week" section_holidays_and_closures: "Holidays and closures" diff --git a/config/routes.rb b/config/routes.rb index e1f69b32ed3c..848f047e01d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -765,6 +765,53 @@ get :new_link end end + + resources :user_custom_fields, controller: "/admin/settings/user_custom_fields" do + member do + delete "options/:option_id", action: "delete_option", as: :delete_option_of + post :reorder_alphabetical + put :move + put :drop + + get :attribute_help_text + put :update_attribute_help_text + + get :list_items + end + + resources :items, controller: "/admin/settings/user_custom_fields/hierarchy/items" do + member do + get :change_parent, action: :change_parent_dialog + post :change_parent, action: :change_parent + get :delete, action: :deletion_dialog + get :item_actions + post :move + get :new_child, action: :new + post :new_child, action: :create + end + end + end + + resources :user_custom_field_sections, controller: "/admin/settings/user_custom_field_sections", + only: %i[create update destroy] do + member do + put :move + put :drop + end + collection do + get :new_link + end + resources :built_in_attributes, + controller: "/admin/settings/user_custom_field_sections/built_in_attributes", + param: :key, + only: [] do + member do + put :move + put :drop + end + end + end + resource :working_days_and_hours, controller: "/admin/settings/working_days_and_hours_settings", only: %i[show update] do post :confirm_changes end diff --git a/db/migrate/20260528144922_create_default_user_custom_field_section.rb b/db/migrate/20260528144922_create_default_user_custom_field_section.rb new file mode 100644 index 000000000000..89f0e9d65199 --- /dev/null +++ b/db/migrate/20260528144922_create_default_user_custom_field_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateDefaultUserCustomFieldSection < ActiveRecord::Migration[8.1] + def up + section_id = execute(<<~SQL.squish).first["id"] + INSERT INTO custom_field_sections (type, name, position, created_at, updated_at) + VALUES ('UserCustomFieldSection', NULL, 1, NOW(), NOW()) + RETURNING id + SQL + + execute(<<~SQL.squish) + UPDATE custom_fields + SET custom_field_section_id = #{section_id} + WHERE type = 'UserCustomField' + AND custom_field_section_id IS NULL + SQL + end + + def down + execute(<<~SQL.squish) + UPDATE custom_fields + SET custom_field_section_id = NULL + WHERE type = 'UserCustomField' + AND custom_field_section_id IN ( + SELECT id FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' AND name IS NULL + ) + SQL + + execute(<<~SQL.squish) + DELETE FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' AND name IS NULL + SQL + end +end diff --git a/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb b/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb new file mode 100644 index 000000000000..ee5def0536cd --- /dev/null +++ b/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddVisibleOnUserCardToCustomFields < ActiveRecord::Migration[8.1] + def change + add_column :custom_fields, :visible_on_user_card, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb b/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb new file mode 100644 index 000000000000..09ce5f2d2392 --- /dev/null +++ b/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class AddAttributeOrderToCustomFieldSections < ActiveRecord::Migration[8.0] + def up + add_column :custom_field_sections, :attribute_order, :string, array: true, null: false, default: [] + # Populate attribute_order for every section from the existing position ordering. + # COALESCE handles sections that currently have no custom fields (ARRAY_AGG returns NULL). + execute(<<~SQL.squish) + UPDATE custom_field_sections cs + SET attribute_order = COALESCE( + ( + SELECT ARRAY_AGG('cf_' || cf.id::text ORDER BY cf.position_in_custom_field_section) + FROM custom_fields cf + WHERE cf.custom_field_section_id = cs.id + ), + '{}' + ) + WHERE cs.type = 'UserCustomFieldSection' + SQL + + # Prepend the five built-in user attributes to the first UserCustomFieldSection + # (the one with the lowest position). This is idempotent if run more than once. + execute(<<~SQL.squish) + UPDATE custom_field_sections + SET attribute_order = + ARRAY['login', 'firstname', 'lastname', 'mail', 'language'] || attribute_order + WHERE id = ( + SELECT id FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' + ORDER BY position + LIMIT 1 + ) + SQL + end + + def down + # Restore per-section positions from attribute_order in case they have been + # changed already. + # UNNEST … WITH ORDINALITY yields each key with its 1-based rank. + # Built-in keys (login, firstname, …) don't follow the cf_