From d924c255cf4bc3379810d16ae6c643b1e76517cb Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad <62008897+bsatarnejad@users.noreply.github.com> Date: Wed, 13 May 2026 12:56:18 +0200 Subject: [PATCH] [69524] Primerize Types form configuration page (#22854) * Create the section component * Create the form configuration component * Create a controller * change form template * Use primer dialog for reset to defaults button * show WP configuration modal while creating a related WPs table * Fix the drag and drop functionality without save button * Fix renaming functionality * Use generic drag and drop in form configuration and move all client side action handling to server side * Fix embedded query form configuration regressions * Add data test selectors to all elements that we used for test * update the current tests with the new implementations and design * Add new tests for new controller of sections and rows * WP quesry row should only have edit quesry action * Update transformer spec regarding the new changes * Fix the failing test in reset form configuration and some tests for actions * Fix rubocop errors * Fix eslint errors * Add spec for removing a section * Use condensed border boxes * fix failing specs * fix failing specs * Switch the buttons in form configuration component * Create the section at the top of the list * Instead of using UUID, use the name of the group as the key * Add missing check for EE for section actions * Remove angular components * Use action list instead of a border box for left side panel * Reduce the margin between the right side panel and sub header, add some space to the query table left side, span to the whole available space * Show validation errors while updating and creating a section * Use a danger dialog for reset to default * Add a confirmation for removing the section * Align items in the row * Use test_selector instead of data-test-selector * Create move_action in rb file * Create move_action in rb file * Simplify section component * Simplify form configuration component * Remove dialog for rename and delete section on missing EE * Create a component for inactive attribute list * Create a separate component for reset dialog * Remove EE feedback dialog * Remove form partial which is not needed anymore * Remove unused js strings * Update using update_via_turbo * Remove form configuration rows controller * Create a blanksalte component * Fix failing specs * Fix failing specs * Fix failing specs * Remove unused translation strings * Align form configuration section routes with actual create flow * Change section to group * Change section to group in services and controllers * Change section to group in en.yml * Fix rubocop errors * Move the query group persistence assertion from the JS feature spec to the synchronous form configuration groups controller spec. * Reuse query service result in embedded query build * Keep inactive attribute filter after turbo list refresh * Extract form configuration group edit state into form model * Fix the failing test * Potential fix for pull request finding 'CodeQL / Potentially uninitialized local variable' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Rename inactive attribute component inputs * Refine reset dialog wording for form configuration * Use direct Turbo action for adding attribute groups * Reuse generic filter-list controller for inactive attributes * Remove focus impelementation in ts * Group form configuration Stimulus controllers under one namespace * Use turbo request service in form configuration controller * Clarify legacy group key normalization in form config contract * Replace inactive attribute list wrapper via turbo stream * Extract duplicate untitled group key generation into Type::FormGroup.next_untitled_key * Auto-generate untitled group name on create instead of returning an error * Avoid mixed return types in form config group create service * Extract shared form configuration group service behavior into concern * Fix spacing for the last group and italic font for the placeholder rows * Replace Angular no-results component with Primer Banner on form configuration page * Hide dropped element immediately to prevent flickering before Turbo Stream response * Reload type before rendering create error to prevent duplicate groups * Await service initialization before use to prevent potential race condition * Replace sleep calls with deterministic waits in form configuration spec * Handle malformed JSON and invalid query errors gracefully in form configuration update * Make query group label a clickable button and empty group hint italic --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../blankslate_component.html.erb | 7 + .../blankslate_component.rb | 36 ++ .../group_attribute_row_component.html.erb | 74 ++++ .../group_attribute_row_component.rb | 82 +++++ .../group_component.html.erb | 94 +++++ .../form_configuration/group_component.rb | 143 ++++++++ .../group_header_component.html.erb | 99 +++++ .../group_header_component.rb | 119 ++++++ .../group_query_row_component.html.erb | 49 +++ .../group_query_row_component.rb | 49 +++ ...nactive_attributes_list_component.html.erb | 43 +++ .../inactive_attributes_list_component.rb | 67 ++++ ...tive_attributes_sidebar_component.html.erb | 42 +++ .../inactive_attributes_sidebar_component.rb | 43 +++ .../main_content_component.html.erb | 73 ++++ .../main_content_component.rb | 67 ++++ .../reset_dialog_component.html.erb | 24 ++ .../reset_dialog_component.rb | 42 +++ .../form_configuration_component.html.erb | 54 +++ .../form_configuration_component.rb | 70 ++++ .../update_form_configuration_contract.rb | 48 ++- .../form_configuration_component_streams.rb | 94 +++++ ...orm_configuration_groups_tab_controller.rb | 240 ++++++++++++ .../form_configuration_tab_controller.rb | 75 +++- .../form_configuration/group_form.rb | 83 +++++ app/helpers/types_helper.rb | 5 +- app/models/permitted_params.rb | 2 +- app/models/type/attribute_group.rb | 3 +- app/models/type/attribute_groups.rb | 21 +- app/models/type/form_group.rb | 27 +- .../form_configuration/group_form_model.rb | 61 ++++ .../attribute_groups/transformer.rb | 63 +++- .../form_configuration/concern.rb | 163 +++++++++ .../embedded_query_builder.rb | 86 +++++ .../create_service.rb | 77 ++++ .../delete_service.rb | 53 +++ .../update_service.rb | 129 +++++++ .../form_configuration_rows/delete_service.rb | 55 +++ .../form_configuration_rows/update_service.rb | 139 +++++++ .../work_package_types/update_service.rb | 51 ++- .../custom_styles/_primer_color_mapping.erb | 1 - .../form_configuration_tab/_form.html.erb | 71 ---- .../form_configuration_tab/edit.html.erb | 26 +- config/locales/en.yml | 32 +- config/locales/js-en.yml | 22 -- config/routes.rb | 22 +- frontend/src/app/app.module.ts | 2 - .../admin/openproject-admin.module.ts | 10 - .../types/attribute-group.component.html | 43 --- .../admin/types/attribute-group.component.ts | 40 -- .../types/group-edit-in-place.component.ts | 108 ------ .../admin/types/group-edit-in-place.html | 23 -- .../admin/types/query-group.component.html | 28 -- .../admin/types/query-group.component.ts | 33 -- .../admin/types/type-banner.service.ts | 34 -- .../type-form-configuration.component.ts | 274 -------------- .../admin/types/type-form-configuration.html | 90 ----- ...table-configuration-relation-selector.html | 1 + .../external-query-configuration.component.ts | 44 ++- .../content/_types_form_configuration.sass | 155 ++++---- .../openproject/_variable_defaults.scss | 1 - .../drag-and-drop.controller.ts | 64 ++++ .../main.controller.ts | 154 ++++++++ .../rows-drag-and-drop.controller.ts | 63 ++++ ...update_form_configuration_contract_spec.rb | 40 ++ ...onfiguration_groups_tab_controller_spec.rb | 219 +++++++++++ ..._configuration_tab_controller_rows_spec.rb | 68 ++++ .../form_configuration_tab_controller_spec.rb | 18 + .../types/form_configuration_query_spec.rb | 44 +-- .../features/types/form_configuration_spec.rb | 317 +++++++++++++--- .../types/reset_form_configuration_spec.rb | 35 +- spec/helpers/types_helper_spec.rb | 9 + .../attribute_groups/transformer_spec.rb | 26 +- .../create_service_spec.rb | 70 ++++ .../update_service_spec.rb | 73 ++++ .../work_package_types/update_service_spec.rb | 26 ++ .../admin/type_configuration_form.rb | 344 ++++++++++++------ .../table_configuration_modal.rb | 7 + .../embedded_work_packages_table.rb | 2 +- 79 files changed, 4225 insertions(+), 1166 deletions(-) create mode 100644 app/components/work_package_types/form_configuration/blankslate_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/blankslate_component.rb create mode 100644 app/components/work_package_types/form_configuration/group_attribute_row_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/group_attribute_row_component.rb create mode 100644 app/components/work_package_types/form_configuration/group_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/group_component.rb create mode 100644 app/components/work_package_types/form_configuration/group_header_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/group_header_component.rb create mode 100644 app/components/work_package_types/form_configuration/group_query_row_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/group_query_row_component.rb create mode 100644 app/components/work_package_types/form_configuration/inactive_attributes_list_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/inactive_attributes_list_component.rb create mode 100644 app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.rb create mode 100644 app/components/work_package_types/form_configuration/main_content_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/main_content_component.rb create mode 100644 app/components/work_package_types/form_configuration/reset_dialog_component.html.erb create mode 100644 app/components/work_package_types/form_configuration/reset_dialog_component.rb create mode 100644 app/components/work_package_types/form_configuration_component.html.erb create mode 100644 app/components/work_package_types/form_configuration_component.rb create mode 100644 app/controllers/concerns/work_package_types/form_configuration_component_streams.rb create mode 100644 app/controllers/work_package_types/form_configuration_groups_tab_controller.rb create mode 100644 app/forms/work_package_types/form_configuration/group_form.rb create mode 100644 app/models/work_package_types/form_configuration/group_form_model.rb create mode 100644 app/services/work_package_types/form_configuration/concern.rb create mode 100644 app/services/work_package_types/form_configuration/embedded_query_builder.rb create mode 100644 app/services/work_package_types/form_configuration_groups/create_service.rb create mode 100644 app/services/work_package_types/form_configuration_groups/delete_service.rb create mode 100644 app/services/work_package_types/form_configuration_groups/update_service.rb create mode 100644 app/services/work_package_types/form_configuration_rows/delete_service.rb create mode 100644 app/services/work_package_types/form_configuration_rows/update_service.rb delete mode 100644 app/views/work_package_types/form_configuration_tab/_form.html.erb delete mode 100644 frontend/src/app/features/admin/types/attribute-group.component.html delete mode 100644 frontend/src/app/features/admin/types/attribute-group.component.ts delete mode 100644 frontend/src/app/features/admin/types/group-edit-in-place.component.ts delete mode 100644 frontend/src/app/features/admin/types/group-edit-in-place.html delete mode 100644 frontend/src/app/features/admin/types/query-group.component.html delete mode 100644 frontend/src/app/features/admin/types/query-group.component.ts delete mode 100644 frontend/src/app/features/admin/types/type-banner.service.ts delete mode 100644 frontend/src/app/features/admin/types/type-form-configuration.component.ts delete mode 100644 frontend/src/app/features/admin/types/type-form-configuration.html create mode 100644 frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/drag-and-drop.controller.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/main.controller.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/rows-drag-and-drop.controller.ts create mode 100644 spec/controllers/work_package_types/form_configuration_groups_tab_controller_spec.rb create mode 100644 spec/controllers/work_package_types/form_configuration_tab_controller_rows_spec.rb create mode 100644 spec/services/work_package_types/form_configuration_groups/create_service_spec.rb create mode 100644 spec/services/work_package_types/form_configuration_rows/update_service_spec.rb diff --git a/app/components/work_package_types/form_configuration/blankslate_component.html.erb b/app/components/work_package_types/form_configuration/blankslate_component.html.erb new file mode 100644 index 00000000000..a98ef27cc1b --- /dev/null +++ b/app/components/work_package_types/form_configuration/blankslate_component.html.erb @@ -0,0 +1,7 @@ +<%= + render(Primer::Beta::Blankslate.new(border: true, test_selector: "type-form-configuration-blankslate")) do |blankslate| + blankslate.with_visual_icon(icon: :rows) + blankslate.with_heading(tag: :h3) { t("types.edit.form_configuration.blankslate_title") } + blankslate.with_description { t("types.edit.form_configuration.blankslate_description") } + end +%> diff --git a/app/components/work_package_types/form_configuration/blankslate_component.rb b/app/components/work_package_types/form_configuration/blankslate_component.rb new file mode 100644 index 00000000000..2814c3a1151 --- /dev/null +++ b/app/components/work_package_types/form_configuration/blankslate_component.rb @@ -0,0 +1,36 @@ +# 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 WorkPackageTypes + module FormConfiguration + class BlankslateComponent < ApplicationComponent + end + end +end diff --git a/app/components/work_package_types/form_configuration/group_attribute_row_component.html.erb b/app/components/work_package_types/form_configuration/group_attribute_row_component.html.erb new file mode 100644 index 00000000000..8ad7d6a2a65 --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_attribute_row_component.html.erb @@ -0,0 +1,74 @@ +<%= + flex_layout(justify_content: :space_between, align_items: :center) do |row| + row.with_column(flex_layout: true, align_items: :center, flex: 1) do |left| + left.with_column(mr: 2, classes: "hide-when-print type-form-configuration-page--drag-handle") do + render( + Primer::OpenProject::DragHandle.new( + classes: "attribute-handle", + "aria-label": t("types.edit.form_configuration.drag_to_reorder"), + test_selector: "type-form-configuration-attribute-handle-#{@attribute[:key]}" + ) + ) + end + left.with_column(flex: 1) do + render(Primer::Beta::Text.new) { @attribute[:translation] } + end + end + + row.with_column(classes: "hide-when-print type-form-configuration-page--actions") do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + scheme: :invisible, + size: :small, + classes: "type-form-configuration-page--actions-button", + test_selector: "type-form-configuration-attribute-actions-#{@attribute[:key]}", + "aria-label": t("types.edit.form_configuration.row_actions") + ) + + if attribute_can_move_up? + move_action( + menu:, + href: row_move_path(:highest), + label: t("label_agenda_item_move_to_top"), + icon: "move-to-top" + ) + move_action( + menu:, + href: row_move_path(:higher), + label: t("label_agenda_item_move_up"), + icon: "chevron-up" + ) + end + + if attribute_can_move_down? + move_action( + menu:, + href: row_move_path(:lower), + label: t("label_agenda_item_move_down"), + icon: "chevron-down" + ) + move_action( + menu:, + href: row_move_path(:lowest), + label: t("label_agenda_item_move_to_bottom"), + icon: "move-to-bottom" + ) + end + + menu.with_divider if show_delete_divider? + + menu.with_item( + label: t("button_delete"), + test_selector: "type-form-configuration-delete-attribute-#{@attribute[:key]}", + scheme: :danger, + tag: :a, + href: row_destroy_path, + content_arguments: { data: { turbo_method: :delete, turbo_stream: true } } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end +%> diff --git a/app/components/work_package_types/form_configuration/group_attribute_row_component.rb b/app/components/work_package_types/form_configuration/group_attribute_row_component.rb new file mode 100644 index 00000000000..38f8d859adb --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_attribute_row_component.rb @@ -0,0 +1,82 @@ +# 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 WorkPackageTypes + module FormConfiguration + class GroupAttributeRowComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(attribute:, type:, index:, total_count:) + super + @attribute = attribute + @type = type + @index = index + @total_count = total_count + end + + private + + def multiple_attributes? + @total_count > 1 + end + + def attribute_can_move_up? + multiple_attributes? && !@index.zero? + end + + def attribute_can_move_down? + multiple_attributes? && @index != @total_count - 1 + end + + def show_delete_divider? + attribute_can_move_up? || attribute_can_move_down? + end + + def row_move_path(move_to) + move_type_form_configuration_row_path(@type, @attribute[:key], move_to:) + end + + def row_destroy_path + type_form_configuration_row_path(@type, @attribute[:key]) + end + + def move_action(menu:, href:, label:, icon:) + menu.with_item( + label:, + tag: :a, + href:, + content_arguments: { data: { turbo_method: :put, turbo_stream: true } } + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/group_component.html.erb b/app/components/work_package_types/form_configuration/group_component.html.erb new file mode 100644 index 00000000000..fcd29a0c9e5 --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_component.html.erb @@ -0,0 +1,94 @@ +<%#-- 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. + +++#%> + +<%= + component_wrapper( + data: wrapper_data + ) do + render(border_box_container(mt: 3, mb: (last? ? 3 : 0), padding: :condensed, data: row_drop_target_config)) do |box| + box.with_header(font_weight: :bold) do + render( + WorkPackageTypes::FormConfiguration::GroupHeaderComponent.new( + group: @group, + type: @type, + ee_available: ee_available?, + first: first?, + last: last?, + edit_mode: edit_mode?, + form_model: @form_model + ) + ) + end + + if query_group? + box.with_row do + render( + WorkPackageTypes::FormConfiguration::GroupQueryRowComponent.new( + group: @group, + ee_available: ee_available? + ) + ) + end + elsif attributes.empty? + box.with_row(data: { "empty-list-item": true }) do + flex_layout(align_items: :center) do |empty_row| + empty_row.with_column(mr: 2, classes: "type-form-configuration-page--row-alignment-spacer") + empty_row.with_column(flex: 1) do + render(Primer::Beta::Text.new(color: :subtle, font_style: :italic)) do + t("types.edit.form_configuration.empty_group_hint") + end + end + end + end + else + attributes.each_with_index do |attribute, index| + box.with_row( + data: { + attr_key: attribute[:key], + attr_translation: attribute[:translation], + attr_is_cf: attribute[:is_cf], + "draggable-id": attribute[:key], + "draggable-type": "attribute", + "drop-url": row_drop_path(attribute) + } + ) do + render( + WorkPackageTypes::FormConfiguration::GroupAttributeRowComponent.new( + attribute:, + type: @type, + index:, + total_count: attributes.length + ) + ) + end + end + end + end + end +%> diff --git a/app/components/work_package_types/form_configuration/group_component.rb b/app/components/work_package_types/form_configuration/group_component.rb new file mode 100644 index 00000000000..ad825da1e6a --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_component.rb @@ -0,0 +1,143 @@ +# 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 WorkPackageTypes + module FormConfiguration + class GroupComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(group:, type: nil, ee_available: false, first: false, last: false, edit_mode: false, + form_model: nil) + super(group) + @group = group + @type = type + @ee_available = ee_available + @first = first + @last = last + @edit_mode = edit_mode + @form_model = form_model + @instance_uid = SecureRandom.hex(4) + end + + def wrapper_uniq_by + @group[:key].presence || @instance_uid + end + + def edit_mode? + @edit_mode + end + + def query_group? + @group[:type].to_s == "query" + end + + def attributes + @group[:attributes] || [] + end + + def first? + @first + end + + def last? + @last + end + + def ee_available? + @ee_available + end + + private + + def wrapper_data + { + group_type: @group[:type].to_s, + group_key: @group[:key].to_s, + group_query: @group[:query], + edit_mode: (true if edit_mode?) + }.compact.merge(draggable_item_config) + end + + def group_name + @group[:name] + end + + def temporary_group? + @group[:temporary] + end + + def draggable_item_config + return {} if @group[:key].blank? || temporary_group? + + { + "draggable-id": @group[:key], + "draggable-type": "group", + "drop-url": drop_type_form_configuration_group_path(@type, @group[:key]) + } + end + + def row_drop_target_config + return {} if query_group? || @group[:key].blank? || temporary_group? + + { + "admin--type-form-configuration--rows-drag-and-drop-target": "container", + "target-container-accessor": ".Box > ul", + "target-id": @group[:key], + "target-allowed-drag-type": "attribute" + } + end + + def edit_path + edit_type_form_configuration_group_path(@type, @group[:key]) + end + + def update_path + type_form_configuration_group_path(@type, @group[:key]) + end + + def cancel_edit_path + cancel_edit_type_form_configuration_group_path(@type, @group[:key]) + end + + def move_path(move_to) + move_type_form_configuration_group_path(@type, @group[:key], move_to:) + end + + def destroy_path + type_form_configuration_group_path(@type, @group[:key]) + end + + def row_drop_path(attribute) + drop_type_form_configuration_row_path(@type, attribute[:key]) + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/group_header_component.html.erb b/app/components/work_package_types/form_configuration/group_header_component.html.erb new file mode 100644 index 00000000000..b4a49ebe404 --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_header_component.html.erb @@ -0,0 +1,99 @@ +<% if edit_mode? %> + <%= + primer_form_with( + model: form_model, + scope: :group, + method: form_method, + url: update_path, + test_selector: "type-form-configuration-group-edit-form", + data: { + turbo_stream: true + } + ) do |form| + render( + WorkPackageTypes::FormConfiguration::GroupForm.new( + form, + cancel_path: cancel_edit_path + ) + ) + end + %> +<% else %> + <%= + flex_layout(justify_content: :space_between, align_items: :center) do |header| + header.with_column(flex_layout: true, align_items: :center, flex: 1) do |left| + left.with_column(mr: 2, classes: "hide-when-print type-form-configuration-page--drag-handle") do + render( + Primer::OpenProject::DragHandle.new( + classes: "group-handle", + "aria-label": t("types.edit.form_configuration.drag_to_reorder"), + test_selector: "type-form-configuration-group-handle-#{@group[:key]}" + ) + ) + end + left.with_column(flex: 1) do + render(Primer::Beta::Text.new(font_weight: :bold)) { group_name } + end + end + + header.with_column(classes: "hide-when-print type-form-configuration-page--actions") do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + scheme: :invisible, + size: :small, + classes: "type-form-configuration-page--actions-button", + test_selector: "type-form-configuration-group-actions-#{@group[:key]}", + "aria-label": t("types.edit.form_configuration.group_actions") + ) + + if ee_available? + with_item_group(menu) do + menu.with_item( + label: t("types.edit.form_configuration.rename_group"), + test_selector: "type-form-configuration-group-rename-#{@group[:key]}", + tag: :a, + href: edit_path, + content_arguments: { data: { turbo_stream: true } } + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + end + + with_item_group(menu) do + unless first? + move_action(menu:, href: move_path(:highest), label: t("label_agenda_item_move_to_top"), icon: "move-to-top") + move_action(menu:, href: move_path(:higher), label: t("label_agenda_item_move_up"), icon: "chevron-up") + end + + unless last? + move_action(menu:, href: move_path(:lower), label: t("label_agenda_item_move_down"), icon: "chevron-down") + move_action(menu:, href: move_path(:lowest), label: t("label_agenda_item_move_to_bottom"), icon: "move-to-bottom") + end + end + + if ee_available? + with_item_group(menu) do + menu.with_item( + label: t("button_delete"), + scheme: :danger, + tag: :a, + href: destroy_path, + content_arguments: { + data: { + turbo_method: :delete, + turbo_stream: true, + turbo_confirm: t("types.edit.form_configuration.confirm_delete_group") + } + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end + end + %> +<% end %> diff --git a/app/components/work_package_types/form_configuration/group_header_component.rb b/app/components/work_package_types/form_configuration/group_header_component.rb new file mode 100644 index 00000000000..fc796b936a2 --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_header_component.rb @@ -0,0 +1,119 @@ +# 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 WorkPackageTypes + module FormConfiguration + class GroupHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(group:, type:, ee_available:, first:, last:, edit_mode:, form_model: nil) + super + @group = group + @type = type + @ee_available = ee_available + @first = first + @last = last + @edit_mode = edit_mode + @form_model = form_model + end + + def edit_mode? + @edit_mode + end + + private + + def group_name + @group[:name] + end + + def form_model + @form_model || WorkPackageTypes::FormConfiguration::GroupFormModel.from_group(@group) + end + + def ee_available? + @ee_available + end + + def first? + @first + end + + def last? + @last + end + + def edit_path + edit_type_form_configuration_group_path(@type, @group[:key]) + end + + def update_path + return create_path if temporary_group? + + type_form_configuration_group_path(@type, @group[:key]) + end + + def form_method + temporary_group? ? :post : :patch + end + + def cancel_edit_path + cancel_edit_type_form_configuration_group_path(@type, @group[:key]) + end + + def move_path(move_to) + move_type_form_configuration_group_path(@type, @group[:key], move_to:) + end + + def destroy_path + type_form_configuration_group_path(@type, @group[:key]) + end + + def temporary_group? + @group[:temporary] + end + + def create_path + type_form_configuration_groups_path(@type) + end + + def move_action(menu:, href:, label:, icon:) + menu.with_item( + label:, + tag: :a, + href:, + content_arguments: { data: { turbo_method: :put, turbo_stream: true } } + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/group_query_row_component.html.erb b/app/components/work_package_types/form_configuration/group_query_row_component.html.erb new file mode 100644 index 00000000000..771902361c5 --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_query_row_component.html.erb @@ -0,0 +1,49 @@ +<%= + flex_layout(justify_content: :space_between, align_items: :center) do |row| + row.with_column(flex_layout: true, align_items: :center, flex: 1) do |left| + left.with_column(mr: 2, classes: "type-form-configuration-page--row-alignment-spacer") + left.with_column(flex: 1) do + if ee_available? + render( + Primer::Beta::Button.new( + scheme: :link, + data: { action: "click->admin--type-form-configuration--main#editQuery" } + ) + ) do + t("types.edit.form_configuration.query_group_label") + end + else + render(Primer::Beta::Text.new(color: :subtle, font_style: :italic)) do + t("types.edit.form_configuration.query_group_label") + end + end + end + end + + if ee_available? + row.with_column(classes: "hide-when-print type-form-configuration-page--actions") do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + scheme: :invisible, + size: :small, + classes: "type-form-configuration-page--actions-button", + test_selector: "type-form-configuration-query-actions-#{@group[:key]}", + "aria-label": t("types.edit.form_configuration.row_actions") + ) + + menu.with_item( + label: t("types.edit.form_configuration.edit_query"), + test_selector: "type-form-configuration-edit-query-#{@group[:key]}", + tag: :button, + content_arguments: { + data: { action: "click->admin--type-form-configuration--main#editQuery" } + } + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + end + end + end +%> diff --git a/app/components/work_package_types/form_configuration/group_query_row_component.rb b/app/components/work_package_types/form_configuration/group_query_row_component.rb new file mode 100644 index 00000000000..20e0073f58a --- /dev/null +++ b/app/components/work_package_types/form_configuration/group_query_row_component.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 WorkPackageTypes + module FormConfiguration + class GroupQueryRowComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(group:, ee_available:) + super + @group = group + @ee_available = ee_available + end + + private + + def ee_available? + @ee_available + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/inactive_attributes_list_component.html.erb b/app/components/work_package_types/form_configuration/inactive_attributes_list_component.html.erb new file mode 100644 index 00000000000..8e125cd42ac --- /dev/null +++ b/app/components/work_package_types/form_configuration/inactive_attributes_list_component.html.erb @@ -0,0 +1,43 @@ +<%= + component_wrapper( + id: "type-form-configuration-inactive-container", + data: container_data + ) do + render( + Primer::Alpha::ActionList.new( + role: :list, + classes: "type-form-configuration-page--inactive-list", + data: { "test-selector": "type-form-configuration-inactive-list" } + ) + ) do |list| + if @inactive_attributes.empty? + list.with_item( + label: t("types.edit.form_configuration.no_inactive_attributes"), + disabled: true, + classes: "type-form-configuration-page--inactive-empty", + content_arguments: { tag: :div }, + data: { "empty-list-item": true, "filter--filter-list-target": "searchItem" } + ) + else + @inactive_attributes.each do |attribute| + list.with_item( + label: attribute[:translation], + classes: "type-form-configuration-page--inactive-item", + content_arguments: { tag: :div }, + data: item_data(attribute).merge("filter--filter-list-target": "searchItem") + ) do |item| + item.with_leading_visual_raw_content do + render( + Primer::OpenProject::DragHandle.new( + classes: "attribute-handle", + "aria-label": t("types.edit.form_configuration.drag_to_reorder"), + test_selector: "type-form-configuration-attribute-handle-#{attribute[:key]}" + ) + ) + end + end + end + end + end + end +%> diff --git a/app/components/work_package_types/form_configuration/inactive_attributes_list_component.rb b/app/components/work_package_types/form_configuration/inactive_attributes_list_component.rb new file mode 100644 index 00000000000..ff84d60d9d2 --- /dev/null +++ b/app/components/work_package_types/form_configuration/inactive_attributes_list_component.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackageTypes + module FormConfiguration + class InactiveAttributesListComponent < ApplicationComponent + include OpTurbo::Streamable + + def initialize(type:, inactive_attributes:) + super + @type = type + @inactive_attributes = inactive_attributes + end + + private + + def container_data + { + "test-selector": "type-form-configuration-inactive-container", + "admin--type-form-configuration--main-target": "inactiveContainer", + "admin--type-form-configuration--rows-drag-and-drop-target": "container", + "target-container-accessor": "[data-test-selector='type-form-configuration-inactive-list']", + "target-id": "inactive", + "target-allowed-drag-type": "attribute" + } + end + + def item_data(attribute) + { + attr_key: attribute[:key], + attr_translation: attribute[:translation], + attr_is_cf: attribute[:is_cf], + "draggable-id": attribute[:key], + "draggable-type": "attribute", + "drop-url": drop_type_form_configuration_row_path(@type, attribute[:key]) + } + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.html.erb b/app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.html.erb new file mode 100644 index 00000000000..02e359f19d6 --- /dev/null +++ b/app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.html.erb @@ -0,0 +1,42 @@ +<%= + flex_layout(data: { controller: "filter--filter-list" }) do |flex| + flex.with_row(mb: 1) do + render(Primer::Beta::Heading.new(tag: :h3, font_size: 4)) do + t("types.edit.form_configuration.inactive_attributes_heading") + end + end + + flex.with_row(mb: 3) do + render(Primer::Beta::Text.new(color: :subtle, font_size: :small)) do + t("types.edit.form_configuration.drag_to_activate") + end + end + + flex.with_row(mb: 3) do + render( + Primer::Alpha::TextField.new( + name: "inactive-filter", + label: t("types.edit.form_configuration.filter_inactive"), + visually_hide_label: true, + placeholder: t("types.edit.form_configuration.filter_inactive"), + leading_visual: { icon: :search }, + size: :medium, + "full-width": true, + data: { + action: "input->filter--filter-list#filterLists", + "filter--filter-list-target": "filter" + } + ) + ) + end + + flex.with_row do + render( + WorkPackageTypes::FormConfiguration::InactiveAttributesListComponent.new( + inactive_attributes: @inactive_attributes, + type: @type + ) + ) + end + end +%> diff --git a/app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.rb b/app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.rb new file mode 100644 index 00000000000..db4634d6127 --- /dev/null +++ b/app/components/work_package_types/form_configuration/inactive_attributes_sidebar_component.rb @@ -0,0 +1,43 @@ +# 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 WorkPackageTypes + module FormConfiguration + class InactiveAttributesSidebarComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(type:, inactive_attributes:) + super + @type = type + @inactive_attributes = inactive_attributes + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/main_content_component.html.erb b/app/components/work_package_types/form_configuration/main_content_component.html.erb new file mode 100644 index 00000000000..2c6a832a94a --- /dev/null +++ b/app/components/work_package_types/form_configuration/main_content_component.html.erb @@ -0,0 +1,73 @@ +<%= + component_wrapper( + class: "type-form-configuration-page--main-inner", + data: main_inner_data + ) do + flex_layout do |main| + main.with_row do + render(Primer::OpenProject::SubHeader.new(mb: 0)) do |subheader| + subheader.with_action_button( + tag: :a, + scheme: :secondary, + leading_icon: :undo, + label: t("types.edit.form_configuration.reset_to_defaults"), + href: reset_dialog_type_form_configuration_path(@type), + "data-test-selector": "type-form-configuration-reset-button", + data: { controller: "async-dialog" } + ) do + t("types.edit.form_configuration.reset_to_defaults") + end + + if ee_available? + subheader.with_action_menu( + leading_icon: :plus, + trailing_icon: :"triangle-down", + label: t(:button_add), + anchor_align: :end, + button_arguments: { + scheme: :primary, + "aria-label": t(:button_add), + "data-test-selector": "type-form-configuration-add-button" + } + ) do |menu| + menu.with_item( + label: t("types.edit.form_configuration.add_attribute_group"), + tag: :a, + href: add_group_type_form_configuration_groups_path(@type, group_type: :attribute), + content_arguments: { + data: { turbo_method: :post, turbo_stream: true } + } + ) do |item| + item.with_leading_visual_icon(icon: :rows) + end + + menu.with_item( + label: t("types.edit.form_configuration.add_query_group"), + tag: :button, + content_arguments: { + data: { action: "click->admin--type-form-configuration--main#addQueryGroup" } + } + ) do |item| + item.with_leading_visual_icon(icon: :table) + end + end + end + end + end + + main.with_row do + content_tag( + :div, + id: "type-form-configuration-groups-container", + data: groups_container_data + ) do + if @group_components.empty? + render(WorkPackageTypes::FormConfiguration::BlankslateComponent.new) + else + safe_join(@group_components.map { |group| render(group) }) + end + end + end + end + end +%> diff --git a/app/components/work_package_types/form_configuration/main_content_component.rb b/app/components/work_package_types/form_configuration/main_content_component.rb new file mode 100644 index 00000000000..bae22743782 --- /dev/null +++ b/app/components/work_package_types/form_configuration/main_content_component.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackageTypes + module FormConfiguration + class MainContentComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(type:, group_components:, ee_available:) + super + @type = type + @group_components = group_components + @ee_available = ee_available + end + + private + + def ee_available? + @ee_available + end + + def main_inner_data + { + controller: "admin--type-form-configuration--drag-and-drop", + "admin--type-form-configuration--drag-and-drop-handle-selector-value": ".group-handle" + } + end + + def groups_container_data + { + "test-selector": "type-form-configuration-groups-container", + "admin--type-form-configuration--main-target": "groupsContainer", + "admin--type-form-configuration--drag-and-drop-target": "container", + "target-allowed-drag-type": "group" + } + end + end + end +end diff --git a/app/components/work_package_types/form_configuration/reset_dialog_component.html.erb b/app/components/work_package_types/form_configuration/reset_dialog_component.html.erb new file mode 100644 index 00000000000..a77621e16c6 --- /dev/null +++ b/app/components/work_package_types/form_configuration/reset_dialog_component.html.erb @@ -0,0 +1,24 @@ +<%= + render( + Primer::OpenProject::DangerDialog.new( + id: "type-form-configuration-reset-dialog", + test_selector: "type-form-configuration-reset-dialog", + title: t("types.edit.form_configuration.reset_title"), + confirm_button_text: t("button_reset"), + form_arguments: { + action: type_form_configuration_path(@type), + method: :patch, + data: { turbo_stream: true } + } + ) + ) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { t("types.edit.form_configuration.confirm_reset") } + message.with_description_content(t("types.edit.form_configuration.reset_description")) + end + + dialog.with_additional_details do + hidden_field_tag("type[attribute_groups]", "[]") + end + end +%> diff --git a/app/components/work_package_types/form_configuration/reset_dialog_component.rb b/app/components/work_package_types/form_configuration/reset_dialog_component.rb new file mode 100644 index 00000000000..875e62b8b56 --- /dev/null +++ b/app/components/work_package_types/form_configuration/reset_dialog_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackageTypes + module FormConfiguration + class ResetDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + def initialize(type:) + super + @type = type + end + end + end +end diff --git a/app/components/work_package_types/form_configuration_component.html.erb b/app/components/work_package_types/form_configuration_component.html.erb new file mode 100644 index 00000000000..20cfedb36b4 --- /dev/null +++ b/app/components/work_package_types/form_configuration_component.html.erb @@ -0,0 +1,54 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= component_wrapper( + class: "type-form-configuration-page--wrapper", + data: wrapper_data + ) do %> + <%= render(Primer::Alpha::Layout.new(stacking_breakpoint: :md, overflow: :hidden, h: :full, classes: "type-form-configuration-page")) do |content| %> + <% content.with_sidebar(row_placement: :start, col_placement: :start, border: true, border_bottom: 0, p: 3, overflow: :auto, classes: "type-form-configuration-page--sidebar") do %> + <%= render( + WorkPackageTypes::FormConfiguration::InactiveAttributesSidebarComponent.new( + type: @type, + inactive_attributes: @inactive_attributes + ) + ) %> + <% end %> + + <% content.with_main(overflow: :auto, classes: "type-form-configuration-page--main") do %> + <%= render( + WorkPackageTypes::FormConfiguration::MainContentComponent.new( + type: @type, + group_components: group_components, + ee_available: ee_available? + ) + ) %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/work_package_types/form_configuration_component.rb b/app/components/work_package_types/form_configuration_component.rb new file mode 100644 index 00000000000..1d55de92325 --- /dev/null +++ b/app/components/work_package_types/form_configuration_component.rb @@ -0,0 +1,70 @@ +# 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 WorkPackageTypes + class FormConfigurationComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(type:, form_attributes:, no_filter_query:) + super(type) + @type = type + @groups = form_attributes[:actives].reject { |g| g[:key].to_s == "__empty" } + @inactive_attributes = form_attributes[:inactives] + @no_filter_query = no_filter_query + end + + def ee_available? + EnterpriseToken.allows_to?(:edit_attribute_groups) + end + + def wrapper_data + { + controller: "admin--type-form-configuration--main admin--type-form-configuration--rows-drag-and-drop", + "admin--type-form-configuration--main-no-filter-query-value": @no_filter_query, + "admin--type-form-configuration--main-add-group-url-value": add_group_type_form_configuration_groups_path(@type), + "admin--type-form-configuration--main-groups-url-value": type_form_configuration_groups_path(@type), + "admin--type-form-configuration--rows-drag-and-drop-handle-selector-value": ".attribute-handle" + } + end + + def group_components + @groups.map.with_index do |group, i| + WorkPackageTypes::FormConfiguration::GroupComponent.new( + group:, + type: @type, + ee_available: ee_available?, + first: i == 0, + last: i == @groups.length - 1 + ) + end + end + end +end diff --git a/app/contracts/work_package_types/update_form_configuration_contract.rb b/app/contracts/work_package_types/update_form_configuration_contract.rb index 8d14edc2866..4c6ed998532 100644 --- a/app/contracts/work_package_types/update_form_configuration_contract.rb +++ b/app/contracts/work_package_types/update_form_configuration_contract.rb @@ -48,7 +48,9 @@ module WorkPackageTypes seen = Set.new model.attribute_groups.each do |group| errors.add(:attribute_groups, :group_without_name) if group.key.blank? - errors.add(:attribute_groups, :duplicate_group, group: group.key) if seen.add?(group.key).nil? + + group_name = visible_group_name(group) + errors.add(:attribute_groups, :duplicate_group, group: group_name) if seen.add?(group_name).nil? end end @@ -65,14 +67,10 @@ module WorkPackageTypes end def validate_query_group(group) - query = group.query + query_call = rebuild_query_group(group) + return add_invalid_query_error(group, query_call.errors) unless query_call.success? - contract_class = query.persisted? ? Queries::UpdateContract : Queries::CreateContract - contract = contract_class.new(query, user) - - unless contract.validate - errors.add(:attribute_groups, :query_invalid, group: group.key, details: contract.errors.full_messages.join) - end + validate_rebuilt_query_group(group, query_call.result) end def validate_attribute_group(group) @@ -92,10 +90,42 @@ module WorkPackageTypes def custom_groups_modified? return false unless model.attribute_groups_changed? - old_keys = model.attribute_groups_was.map(&:first) + old_keys = normalized_old_keys new_keys = model.attribute_groups.map(&:key) (new_keys - old_keys - Type.default_groups.keys).any? end + + def normalized_old_keys + seen_keys = model.attribute_groups_was.filter_map(&:first).compact_blank.map(&:to_s) + + model.attribute_groups_was.map do |group| + key = group.first.presence&.to_s + key || normalized_legacy_group_key(seen_keys).tap { |legacy_key| seen_keys << legacy_key } + end + end + + def normalized_legacy_group_key(seen_keys) + Type::FormGroup.next_untitled_key(seen_keys) + end + + def visible_group_name(group) + group.translated_key.to_s.strip + end + + def rebuild_query_group(group) + ::WorkPackageTypes::FormConfiguration::EmbeddedQueryBuilder.rebuild(query: group.query, user:) + end + + def validate_rebuilt_query_group(group, query) + contract = Queries::CreateContract.new(query, user) + return if contract.validate + + add_invalid_query_error(group, contract.errors) + end + + def add_invalid_query_error(group, error_collection) + errors.add(:attribute_groups, :query_invalid, group: group.key, details: error_collection.full_messages.to_sentence) + end end end diff --git a/app/controllers/concerns/work_package_types/form_configuration_component_streams.rb b/app/controllers/concerns/work_package_types/form_configuration_component_streams.rb new file mode 100644 index 00000000000..a799d7b709f --- /dev/null +++ b/app/controllers/concerns/work_package_types/form_configuration_component_streams.rb @@ -0,0 +1,94 @@ +# 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 WorkPackageTypes + module FormConfigurationComponentStreams + extend ActiveSupport::Concern + + private + + def update_form_configuration_via_turbo_stream(**) + update_main_content_via_turbo_stream(**) + update_inactive_attributes_via_turbo_stream + end + + def update_main_content_via_turbo_stream(groups: active_groups_for_form, editing_group_key: nil, form_model: nil) + ee_available = EnterpriseToken.allows_to?(:edit_attribute_groups) + group_components = build_group_components( + groups:, + ee_available:, + editing_group_key:, + form_model: + ) + + update_via_turbo_stream( + component: WorkPackageTypes::FormConfiguration::MainContentComponent.new( + type: @type, + group_components:, + ee_available: + ) + ) + end + + def update_inactive_attributes_via_turbo_stream + replace_via_turbo_stream( + component: WorkPackageTypes::FormConfiguration::InactiveAttributesListComponent.new( + inactive_attributes: form_configuration_groups(@type)[:inactives], + type: @type + ), + target: "type-form-configuration-inactive-container" + ) + end + + def render_form_configuration_error(call) + render_error_flash_message_via_turbo_stream(message: call.errors.full_messages.to_sentence) + end + + def build_group_components(groups:, ee_available:, editing_group_key:, form_model:) + groups.map.with_index do |group, index| + is_editing = editing_group_key.present? && group[:key].to_s == editing_group_key.to_s + + WorkPackageTypes::FormConfiguration::GroupComponent.new( + group:, + type: @type, + ee_available:, + first: index.zero?, + last: index == groups.length - 1, + edit_mode: is_editing, + form_model: (form_model if is_editing) + ) + end + end + + def active_groups_for_form + form_configuration_groups(@type)[:actives].reject { |group| group[:key].to_s == "__empty" } + end + end +end diff --git a/app/controllers/work_package_types/form_configuration_groups_tab_controller.rb b/app/controllers/work_package_types/form_configuration_groups_tab_controller.rb new file mode 100644 index 00000000000..a5901f94b8c --- /dev/null +++ b/app/controllers/work_package_types/form_configuration_groups_tab_controller.rb @@ -0,0 +1,240 @@ +# 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 WorkPackageTypes + class FormConfigurationGroupsTabController < BaseTabController + include TypesHelper + include OpTurbo::ComponentStream + include WorkPackageTypes::FormConfigurationComponentStreams + + TEMPORARY_GROUP_KEY = "__new_form_configuration_group__" + + def edit + update_main_content_via_turbo_stream(editing_group_key: group_key_param) + + respond_with_turbo_streams + end + + def add_group + render_temporary_group_editor + + respond_with_turbo_streams + end + + def create + call = create_group_call + + if call.success? + update_form_configuration_via_turbo_stream + else + render_create_error(call) + end + + respond_with_turbo_streams(status: turbo_status_for(call)) + end + + def cancel_edit + if temporary_group_key?(group_key_param) + update_form_configuration_via_turbo_stream + respond_with_turbo_streams + return + end + + group = find_group(group_key_param) + return head :not_found if group.nil? + + update_main_content_via_turbo_stream + respond_with_turbo_streams + end + + def update + call = rename_group_call + + if call.success? + update_form_configuration_via_turbo_stream + else + render_existing_group_update_error(call) + end + + respond_with_turbo_streams(status: turbo_status_for(call)) + end + + def destroy + call = ::WorkPackageTypes::FormConfigurationGroups::DeleteService + .new(user: current_user, type: @type, group_key: group_key_param) + .call + + if call.success? + update_form_configuration_via_turbo_stream + else + render_form_configuration_error(call) + end + + respond_with_turbo_streams(status: turbo_status_for(call)) + end + + def drop + call = ::WorkPackageTypes::FormConfigurationGroups::UpdateService + .new(user: current_user, type: @type, group_key: group_key_param) + .call(position: params[:position]) + + if call.success? + update_main_content_via_turbo_stream + else + render_form_configuration_error(call) + end + + respond_with_turbo_streams(status: turbo_status_for(call)) + end + + def move + call = ::WorkPackageTypes::FormConfigurationGroups::UpdateService + .new(user: current_user, type: @type, group_key: group_key_param) + .call(move_to: params[:move_to]) + + if call.success? + update_main_content_via_turbo_stream + else + render_form_configuration_error(call) + end + + respond_with_turbo_streams(status: turbo_status_for(call)) + end + + def update_query + call = ::WorkPackageTypes::FormConfigurationGroups::UpdateService + .new(user: current_user, type: @type, group_key: group_key_param) + .call(query_props: params[:query]) + + if call.success? + head :ok + else + render_form_configuration_error(call) + respond_with_turbo_streams(status: turbo_status_for(call)) + end + end + + private + + def group_params + params.expect(group: %i[name group_type query]) + end + + def find_group(key) + @type.attribute_groups.find do |group| + [ + group.key, + group.display_name, + group.translated_key + ].compact.map(&:to_s).include?(key.to_s) + end + end + + def group_key_param + params[:key] || params[:id] + end + + def temporary_group_key?(key) + key.to_s == TEMPORARY_GROUP_KEY + end + + def temporary_group(group_type:, query:, name: "") + { + key: TEMPORARY_GROUP_KEY, + type: group_type.to_s, + name:, + attributes: [], + query:, + temporary: true + } + end + + def turbo_status_for(call) + call.success? ? :ok : :unprocessable_entity + end + + def create_group_call + ::WorkPackageTypes::FormConfigurationGroups::CreateService + .new(user: current_user, type: @type) + .call( + group_type: group_params[:group_type], + name: group_params[:name], + query_props: group_params[:query] + ) + end + + def rename_group_call + ::WorkPackageTypes::FormConfigurationGroups::UpdateService + .new(user: current_user, type: @type, group_key: group_key_param) + .call(name: group_params[:name]) + end + + def render_create_error(call) + @type.reload + group = temporary_group( + group_type: group_params[:group_type], + query: group_params[:query], + name: group_params[:name].to_s + ) + + render_temporary_group_editor( + group:, + form_model: group_form_model(group:, validation_message: call.errors.map(&:message).to_sentence) + ) + end + + def render_existing_group_update_error(call) + @type.reload + group = active_groups_for_form.find { |active_group| active_group[:key].to_s == group_key_param.to_s } + + update_main_content_via_turbo_stream( + editing_group_key: group_key_param, + form_model: group_form_model( + group:, + name: group_params[:name].to_s, + validation_message: call.errors.map(&:message).to_sentence + ) + ) + end + + def render_temporary_group_editor(group: temporary_group(group_type: params[:group_type], query: params[:query]), + form_model: nil) + update_main_content_via_turbo_stream( + groups: [group] + active_groups_for_form, + editing_group_key: TEMPORARY_GROUP_KEY, + form_model: + ) + end + + def group_form_model(group:, name: group[:name], validation_message: nil) + WorkPackageTypes::FormConfiguration::GroupFormModel.from_group(group, name:, validation_message:) + end + end +end diff --git a/app/controllers/work_package_types/form_configuration_tab_controller.rb b/app/controllers/work_package_types/form_configuration_tab_controller.rb index 3edf8c3b1ef..b094b28b4fd 100644 --- a/app/controllers/work_package_types/form_configuration_tab_controller.rb +++ b/app/controllers/work_package_types/form_configuration_tab_controller.rb @@ -30,36 +30,101 @@ module WorkPackageTypes class FormConfigurationTabController < BaseTabController - include PaginationHelper + include TypesHelper + include OpTurbo::ComponentStream + include WorkPackageTypes::FormConfigurationComponentStreams layout "admin" - current_menu_item [:edit, :update] do + current_menu_item [:edit, :update, :reset_dialog, :move, :drop, :destroy] do :types end def edit; end + def reset_dialog + respond_with_dialog( + WorkPackageTypes::FormConfiguration::ResetDialogComponent.new(type: @type) + ) + end + def update result = WorkPackageTypes::UpdateService .new(user: current_user, model: @type, contract_class: UpdateFormConfigurationContract) .call(permitted_type_params) if result.success? - redirect_to edit_type_form_configuration_path(@type), notice: t(:notice_successful_update) + respond_to_update_success else - flash.now[:error] = result.errors[:attribute_groups].to_sentence - render :edit, status: :unprocessable_entity + respond_to_update_failure(result) end end + def move + call = ::WorkPackageTypes::FormConfigurationRows::UpdateService + .new(user: current_user, type: @type, row_key: row_key_param) + .call(move_to: params[:move_to]) + + handle_row_update_response(call) + end + + def drop + call = ::WorkPackageTypes::FormConfigurationRows::UpdateService + .new(user: current_user, type: @type, row_key: row_key_param) + .call(target_id: params[:target_id], position: params[:position]) + + handle_row_update_response(call) + end + + def destroy + call = ::WorkPackageTypes::FormConfigurationRows::DeleteService + .new(user: current_user, type: @type, row_key: row_key_param) + .call + + handle_row_update_response(call) + end + private + def respond_to_update_success + respond_to do |format| + format.html { redirect_to edit_type_form_configuration_path(@type), notice: t(:notice_successful_update) } + format.turbo_stream do + update_form_configuration_via_turbo_stream + respond_with_turbo_streams + end + end + end + + def respond_to_update_failure(result) + respond_to do |format| + format.html do + flash.now[:error] = result.errors[:attribute_groups].to_sentence + render :edit, status: :unprocessable_entity + end + format.turbo_stream { head :unprocessable_entity } + end + end + + def handle_row_update_response(call) + if call.success? + update_form_configuration_via_turbo_stream + else + render_form_configuration_error(call) + end + + respond_with_turbo_streams(status: call.success? ? :ok : :unprocessable_entity) + end + def find_type @type = ::Type.includes(:projects, :custom_fields).find(params[:type_id]) show_error_not_found unless @type end + def row_key_param + params[:row_key] || params[:id] + end + def permitted_type_params # having to call #to_unsafe_h as a query hash the attribute_groups # parameters would otherwise still be an ActiveSupport::Parameter diff --git a/app/forms/work_package_types/form_configuration/group_form.rb b/app/forms/work_package_types/form_configuration/group_form.rb new file mode 100644 index 00000000000..cee1676e6f4 --- /dev/null +++ b/app/forms/work_package_types/form_configuration/group_form.rb @@ -0,0 +1,83 @@ +# 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 WorkPackageTypes + module FormConfiguration + class GroupForm < ApplicationForm + form do |group_form| + group_form.hidden(name: :group_type, value: model.group_type) + group_form.hidden(name: :query, value: model.query) if model.query.present? + + group_form.group(layout: :horizontal) do |row| + row.text_field( + name: :name, + label: I18n.t("types.edit.form_configuration.group_name_label"), + visually_hide_label: true, + value: model.name, + required: true, + autofocus: true, + autocomplete: "off", + validation_message: validation_message_for(:name), + data: { "test-selector": "type-form-configuration-group-name-input" } + ) + row.button( + name: :cancel, + tag: :a, + label: I18n.t("button_cancel"), + scheme: :secondary, + href: @cancel_path, + data: { + turbo_method: :post, + turbo_stream: true + }, + test_selector: "type-form-configuration-group-cancel" + ) + row.submit( + name: :submit, + label: I18n.t("button_save"), + scheme: :primary, + test_selector: "type-form-configuration-group-save" + ) + end + end + + def initialize(cancel_path:) + super() + @cancel_path = cancel_path + end + + private + + def validation_message_for(attribute) + model.errors.messages_for(attribute).to_sentence.presence + end + end + end +end diff --git a/app/helpers/types_helper.rb b/app/helpers/types_helper.rb index 922a63eec72..9ade9394193 100644 --- a/app/helpers/types_helper.rb +++ b/app/helpers/types_helper.rb @@ -136,13 +136,12 @@ module ::TypesHelper def get_active_groups(type, available, inactive) type.attribute_groups.map do |group| { + key: group.key, type: group.group_type, name: group.translated_key, attributes: active_group_attributes_map(group, available, inactive), query: query_to_query_props(group) - }.tap do |group_obj| - group_obj[:key] = group.key if group.internal_key? - end + } end end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index d0a44db3c3a..6934800f519 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -235,7 +235,7 @@ class PermittedParams whitelisted = type_params.permit(*permitted) if type_params[:attribute_groups] - whitelisted[:attribute_groups] = JSON.parse(type_params[:attribute_groups]) + whitelisted[:attribute_groups] = type_params[:attribute_groups] end whitelisted diff --git a/app/models/type/attribute_group.rb b/app/models/type/attribute_group.rb index 6e742da4a71..bc88702058d 100644 --- a/app/models/type/attribute_group.rb +++ b/app/models/type/attribute_group.rb @@ -47,7 +47,8 @@ class Type::AttributeGroup < Type::FormGroup other.is_a?(self.class) && key == other.key && type == other.type && - attributes == other.attributes + attributes == other.attributes && + display_name == other.display_name end def active_members(project) diff --git a/app/models/type/attribute_groups.rb b/app/models/type/attribute_groups.rb index 74bdbcef5dd..9277c83d50c 100644 --- a/app/models/type/attribute_groups.rb +++ b/app/models/type/attribute_groups.rb @@ -190,14 +190,15 @@ module Type::AttributeGroups attributes = group[1] first_attribute = attributes[0] key = group[0] + display_name = group[2] if group.length > 2 if first_attribute.is_a?(Query) - new_query_group(key, first_attribute) + new_query_group(key, first_attribute, display_name:) elsif first_attribute.is_a?(Symbol) && Type::QueryGroup.query_attribute?(first_attribute) query = Query.find_by(id: Type::QueryGroup.query_attribute_id(first_attribute)) - new_query_group(key, query) + new_query_group(key, query, display_name:) else - new_attribute_group(key, attributes) + new_attribute_group(key, attributes, display_name:) end end end @@ -213,16 +214,18 @@ module Type::AttributeGroups else group.attributes end - [group.key, attributes] + result = [group.key, attributes] + result << group.display_name if group.display_name.present? + result end end - def new_attribute_group(key, attributes) - Type::AttributeGroup.new(self, key, attributes) + def new_attribute_group(key, attributes, display_name: nil) + Type::AttributeGroup.new(self, key, attributes, display_name:) end - def new_query_group(key, query) - Type::QueryGroup.new(self, key, query) + def new_query_group(key, query, display_name: nil) + Type::QueryGroup.new(self, key, query, display_name:) end def cleanup_query_groups_queries @@ -231,7 +234,7 @@ module Type::AttributeGroups new_groups = self[:attribute_groups] old_groups = attribute_groups_was - ids = (old_groups.map(&:last).flatten - new_groups.map(&:last).flatten) + ids = (old_groups.map { |g| g[1] }.flatten - new_groups.map { |g| g[1] }.flatten) .filter_map { |k| ::Type::QueryGroup.query_attribute_id(k) } Query.where(id: ids).destroy_all diff --git a/app/models/type/form_group.rb b/app/models/type/form_group.rb index dbdb95d27af..115447c80d1 100644 --- a/app/models/type/form_group.rb +++ b/app/models/type/form_group.rb @@ -31,12 +31,27 @@ class Type::FormGroup attr_accessor :key, :attributes, - :type + :type, + :display_name - def initialize(type, key, attributes) + def self.next_untitled_key(seen_keys) + base_name = I18n.t("types.edit.form_configuration.untitled_group") + candidate = base_name + suffix = 2 + + while seen_keys.include?(candidate) + candidate = "#{base_name} #{suffix}" + suffix += 1 + end + + candidate + end + + def initialize(type, key, attributes, display_name: nil) self.key = key self.attributes = attributes self.type = type + self.display_name = display_name end ## @@ -49,10 +64,14 @@ class Type::FormGroup # Translate the given attribute group if its internal # (== if it's a symbol) def translated_key - if internal_key? + if display_name.present? + display_name + elsif internal_key? I18n.t(Type.default_groups[key], default: key.to_s) - else + elsif key.present? key + else + I18n.t("types.edit.form_configuration.untitled_group") end end diff --git a/app/models/work_package_types/form_configuration/group_form_model.rb b/app/models/work_package_types/form_configuration/group_form_model.rb new file mode 100644 index 00000000000..b0217d17a7e --- /dev/null +++ b/app/models/work_package_types/form_configuration/group_form_model.rb @@ -0,0 +1,61 @@ +# 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 WorkPackageTypes + module FormConfiguration + class GroupFormModel + include ActiveModel::Model + include ActiveModel::Attributes + include Tableless + + attribute :name, :string + attribute :group_type, :string + attribute :query, :string + attribute :key, :string + attribute :temporary, :boolean, default: false + + def self.model_name + ActiveModel::Name.new(self, nil, "Group") + end + + def self.from_group(group, name: group[:name], validation_message: nil) + new( + name:, + group_type: group[:type], + query: group[:query], + key: group[:key], + temporary: group[:temporary] + ).tap do |form_model| + form_model.errors.add(:name, validation_message) if validation_message.present? + end + end + end + end +end diff --git a/app/services/work_package_types/attribute_groups/transformer.rb b/app/services/work_package_types/attribute_groups/transformer.rb index e367cdc70b6..b9e2d9ecd8e 100644 --- a/app/services/work_package_types/attribute_groups/transformer.rb +++ b/app/services/work_package_types/attribute_groups/transformer.rb @@ -37,15 +37,23 @@ module WorkPackageTypes end def call - return [] if groups.blank? + return ServiceResult.success(result: []) if groups.blank? - groups.map do |group| - if group[:type] == "query" - transform_query_group(group) - else - transform_attribute_group(group) - end + transformed_groups = [] + + groups.each do |group| + transformed_group = if group[:type] == "query" + transform_query_group(group) + else + transform_attribute_group(group) + end + + return transformed_group if transformed_group.is_a?(ServiceResult) + + transformed_groups << transformed_group end + + ServiceResult.success(result: transformed_groups) end private @@ -53,28 +61,43 @@ module WorkPackageTypes attr_reader :groups, :user def transform_attribute_group(group) - name = group[:key]&.to_sym || group[:name] attributes = group[:attributes].pluck(:key) - [name, attributes] + return [group[:name], attributes] if group[:key].blank? + + build_default_attribute_group(group, attributes) end def transform_query_group(group) name = group[:name] - props = JSON.parse(group[:query]) + result = ::WorkPackageTypes::FormConfiguration::EmbeddedQueryBuilder.build( + query_props: group[:query], + name: "Embedded table: #{name}", + user: + ) - query = Query.new_default(name: "Embedded table: #{name}") - query.extend(OpenProject::ChangedBySystem) - query.change_by_system { query.user = User.system } + return result if result.failure? - result = ::API::V3::UpdateQueryFromV3ParamsService - .new(query, user) - .call(props.with_indifferent_access) + [name, [result.result]] + end - if result.success? - query.show_hierarchies = false - [name, [query]] - end + def build_default_attribute_group(group, attributes) + key = group[:key].to_sym + display_name = customized_display_name(group, key) + result = [key, attributes] + result << display_name if display_name.present? + result + end + + def customized_display_name(group, key) + return if group[:name].blank? + + group[:name] unless group[:name] == default_group_name(key) + end + + def default_group_name(key) + label = Type.default_groups[key] + label ? I18n.t(label, default: key.to_s) : key.to_s end end end diff --git a/app/services/work_package_types/form_configuration/concern.rb b/app/services/work_package_types/form_configuration/concern.rb new file mode 100644 index 00000000000..801cb3f055b --- /dev/null +++ b/app/services/work_package_types/form_configuration/concern.rb @@ -0,0 +1,163 @@ +# 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 WorkPackageTypes + module FormConfiguration + module Concern + extend ActiveSupport::Concern + + def initialize(user:, type:, **) + super() + @user = user + @type = type + end + + private + + attr_reader :type, + :user + + def active_groups + type.attribute_groups.reject { |group| group.key.to_s == "__empty" } + end + + def find_group(group_key) + active_groups.find { |group| group_identifier_match?(group, group_key) } + end + + def find_attribute_group(group_key) + type.attribute_groups.find do |group| + group.group_type == :attribute && group_identifier_match?(group, group_key) + end + end + + def find_row(row_key) + active_groups + .select { |group| group.group_type == :attribute } + .each do |group| + index = group.attributes.index { |attribute| attribute_identifier_match?(attribute, row_key) } + return { group:, index: } if index + end + + nil + end + + def group_identifier_match?(group, identifier) + expected = identifier.to_s.strip + + [ + group.key, + group.display_name, + group.translated_key + ].compact.map { |value| value.to_s.strip }.include?(expected) + end + + def attribute_identifier_match?(attribute, identifier) + attribute.to_s.strip == identifier.to_s.strip + end + + def persist_groups(groups) + assign_groups(groups) + return contract_failure unless form_configuration_contract.validate + + persist_type + end + + def failure_with_message(message) + type.errors.clear + type.errors.add(:base, message) + + ServiceResult.failure(result: type, errors: type.errors) + end + + def build_query(query_props, name:) + ::WorkPackageTypes::FormConfiguration::EmbeddedQueryBuilder.build(query_props:, name:, user:) + end + + def normalized_groups(groups) + groups = groups + .reject { |group| group.key.to_s == "__empty" } + seen_keys = groups.filter_map(&:key).compact_blank.map(&:to_s) + groups = groups.map { |group| normalize_group(group, seen_keys:) } + + if groups.empty? + [::Type::AttributeGroup.new(type, :__empty, [])] + else + groups + end + end + + def normalize_group(group, seen_keys:) + return group if group.key.present? + + group.key = next_untitled_group_name(seen_keys) + group + end + + def next_untitled_group_name(seen_keys) + Type::FormGroup.next_untitled_key(seen_keys).tap { |key| seen_keys << key } + end + + def sync_active_custom_fields! + type.custom_field_ids = active_groups + .select { |group| group.group_type == :attribute } + .flat_map(&:members) + .filter_map do |attribute| + next unless CustomField.custom_field_attribute?(attribute) + + attribute.delete_prefix("custom_field_").to_i + end + .uniq + end + + def assign_groups(groups) + type.attribute_groups_will_change! + type.attribute_groups_objects = normalized_groups(groups) + sync_active_custom_fields! + end + + def form_configuration_contract + @form_configuration_contract ||= ::WorkPackageTypes::UpdateFormConfigurationContract.new(type, user, options: {}) + end + + def contract_failure + ServiceResult.failure(result: type, errors: form_configuration_contract.errors) + end + + def persist_type + if type.save + ServiceResult.success(result: type) + else + ServiceResult.failure(result: type, errors: type.errors) + end + end + end + end +end diff --git a/app/services/work_package_types/form_configuration/embedded_query_builder.rb b/app/services/work_package_types/form_configuration/embedded_query_builder.rb new file mode 100644 index 00000000000..2aedb279cf7 --- /dev/null +++ b/app/services/work_package_types/form_configuration/embedded_query_builder.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackageTypes + module FormConfiguration + module EmbeddedQueryBuilder + module_function + + def build(query_props:, name:, user:) + query = Query.new_default(name:) + query.filters = [] + assign_system_user(query) + + query_call = ::API::V3::UpdateQueryFromV3ParamsService + .new(query, user) + .call(query_parameters(query_props), valid_subset: true) + + return query_call if query_call.failure? + + query.show_hierarchies = false + query_call + rescue JSON::ParserError + invalid_query_result + end + + def rebuild(query:, user:) + return invalid_query_result if query.nil? + + build( + query_props: ::API::V3::Queries::QueryParamsRepresenter.new(query).to_h, + name: query.name, + user: + ) + end + + def query_parameters(query_props) + return {} if query_props.blank? + if query_props.is_a?(String) + return JSON.parse(query_props).deep_symbolize_keys + end + + params = query_props.respond_to?(:to_unsafe_h) ? query_props.to_unsafe_h : query_props + params.deep_symbolize_keys + end + + def assign_system_user(query) + query.extend(OpenProject::ChangedBySystem) + query.change_by_system { query.user = User.system } + end + + def invalid_query_result + errors = Query.new.errors + errors.add(:base, I18n.t("types.edit.form_configuration.invalid_query")) + + ServiceResult.failure(errors:) + end + end + end +end diff --git a/app/services/work_package_types/form_configuration_groups/create_service.rb b/app/services/work_package_types/form_configuration_groups/create_service.rb new file mode 100644 index 00000000000..3ee9937e5fe --- /dev/null +++ b/app/services/work_package_types/form_configuration_groups/create_service.rb @@ -0,0 +1,77 @@ +# 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 WorkPackageTypes + module FormConfigurationGroups + class CreateService < ::BaseServices::BaseCallable + include ::WorkPackageTypes::FormConfiguration::Concern + + def perform + name = resolve_group_name + + if query_group? + query_call = build_query(params[:query_props], name: "Embedded table: #{name}") + return query_call if query_call.failure? + end + + group = build_group(name, query_result: query_call&.result) + + groups = active_groups + groups.unshift(group) + + persist_groups(groups).tap do |call| + call.result = group if call.success? + end + end + + private + + def resolve_group_name + name = params[:name].to_s.strip + return name if name.present? + + seen_keys = active_groups.map { |g| g.key.to_s } + Type::FormGroup.next_untitled_key(seen_keys) + end + + def query_group? + params[:group_type].to_s == "query" + end + + def build_group(name, query_result: nil) + if query_result + ::Type::QueryGroup.new(type, name, query_result) + else + ::Type::AttributeGroup.new(type, name, []) + end + end + end + end +end diff --git a/app/services/work_package_types/form_configuration_groups/delete_service.rb b/app/services/work_package_types/form_configuration_groups/delete_service.rb new file mode 100644 index 00000000000..a2851cbdb9e --- /dev/null +++ b/app/services/work_package_types/form_configuration_groups/delete_service.rb @@ -0,0 +1,53 @@ +# 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 WorkPackageTypes + module FormConfigurationGroups + class DeleteService < ::BaseServices::BaseCallable + include ::WorkPackageTypes::FormConfiguration::Concern + + def initialize(user:, type:, group_key:) + super(user:, type:) + @group_key = group_key + end + + def perform + group = find_group(@group_key) + return failure_with_message(I18n.t("types.edit.form_configuration.not_found")) unless group + + groups = active_groups.reject { |group| group.key.to_s == @group_key.to_s } + + persist_groups(groups).tap do |call| + call.result = group if call.success? + end + end + end + end +end diff --git a/app/services/work_package_types/form_configuration_groups/update_service.rb b/app/services/work_package_types/form_configuration_groups/update_service.rb new file mode 100644 index 00000000000..b5999b3e37b --- /dev/null +++ b/app/services/work_package_types/form_configuration_groups/update_service.rb @@ -0,0 +1,129 @@ +# 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 WorkPackageTypes + module FormConfigurationGroups + class UpdateService < ::BaseServices::BaseCallable + include ::WorkPackageTypes::FormConfiguration::Concern + + def initialize(user:, type:, group_key:) + super(user:, type:) + @group_key = group_key + end + + def perform + group = find_group(@group_key) + return failure_with_message(I18n.t("types.edit.form_configuration.not_found")) unless group + + groups = active_groups + update_result = perform_update(group, groups) + return update_result if update_result.is_a?(ServiceResult) && update_result.failure? + + persist_groups(groups).tap do |call| + call.result = group if call.success? + end + end + + private + + def perform_update(group, groups) + return move_group(groups, move_to: params[:move_to], position: params[:position]) if move_requested? + return update_query(group, params[:query_props]) if params[:query_props].present? + + rename_group!(group, params[:name]) + end + + def move_group(groups, move_to:, position:) + current_index = groups.index { |group| group.key.to_s == @group_key.to_s } + return if current_index.nil? + + new_index = group_move_index(groups:, current_index:, move_to:, position:) + + groups.insert(new_index, groups.delete_at(current_index)) if new_index != current_index + end + + def rename_group!(group, name) + stripped_name = name.to_s.strip + return blank_name_error if stripped_name.blank? + + rename_group(group, stripped_name) + nil + end + + def rename_group(group, name) + if group.internal_key? + group.display_name = name.presence == default_name_for(group) ? nil : name.presence + else + group.key = name + group.display_name = nil + end + end + + def default_name_for(group) + I18n.t(Type.default_groups[group.key], default: group.key.to_s) + end + + def move_requested? + params[:move_to].present? || params[:position].present? + end + + def update_query(group, query_props) + query_call = build_query(query_props, name: group.query&.name || "Embedded table: #{@group_key}") + return query_call if query_call.failure? + + group.attributes = query_call.result + nil + end + + def group_move_index(groups:, current_index:, move_to:, position:) + return (position.to_i - 1).clamp(0, groups.length - 1) if position.present? + + case move_to&.to_sym + when :highest + 0 + when :higher + [current_index - 1, 0].max + when :lower + [current_index + 1, groups.length - 1].min + when :lowest + groups.length - 1 + else + current_index + end + end + + def blank_name_error + failure_with_message( + I18n.t("activerecord.errors.models.type.attributes.attribute_groups.group_without_name") + ) + end + end + end +end diff --git a/app/services/work_package_types/form_configuration_rows/delete_service.rb b/app/services/work_package_types/form_configuration_rows/delete_service.rb new file mode 100644 index 00000000000..9c4214bc628 --- /dev/null +++ b/app/services/work_package_types/form_configuration_rows/delete_service.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. +#++ + +module WorkPackageTypes + module FormConfigurationRows + class DeleteService < ::BaseServices::BaseCallable + include ::WorkPackageTypes::FormConfiguration::Concern + + def initialize(user:, type:, row_key:) + super(user:, type:) + @row_key = row_key + end + + def perform + row = find_row(@row_key) + return failure_with_message(I18n.t("types.edit.form_configuration.not_found")) unless row + + attributes = row[:group].attributes.dup + attributes.delete_at(row[:index]) + row[:group].attributes = attributes + + persist_groups(active_groups).tap do |call| + call.result = row[:group] if call.success? + end + end + end + end +end diff --git a/app/services/work_package_types/form_configuration_rows/update_service.rb b/app/services/work_package_types/form_configuration_rows/update_service.rb new file mode 100644 index 00000000000..a33f7038e84 --- /dev/null +++ b/app/services/work_package_types/form_configuration_rows/update_service.rb @@ -0,0 +1,139 @@ +# 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 WorkPackageTypes + module FormConfigurationRows + class UpdateService < ::BaseServices::BaseCallable + include ::WorkPackageTypes::FormConfiguration::Concern + + INACTIVE_TARGET = "inactive" + + def initialize(user:, type:, row_key:) + super(user:, type:) + @row_key = row_key + end + + def perform + return move_row(params[:move_to].to_sym) if move_requested? + return drop_row(target_id: params[:target_id], position: params[:position]) if drop_requested? + + failure_with_message(I18n.t("types.edit.form_configuration.not_found")) + end + + private + + def move_row(move_to) + row = find_row(@row_key) + return failure_with_message(I18n.t("types.edit.form_configuration.not_found")) unless row + + update_row_position(row, move_to) + persist_group_result(row[:group]) + end + + def drop_row(target_id:, position:) + row = find_row(@row_key) + + return drop_row_to_inactive(row) if inactive_target?(target_id) + + target_group = find_attribute_group(target_id) + return failure_with_message(I18n.t("types.edit.form_configuration.not_found")) unless target_group + + move_row_to_target_group(row, target_group, position) + persist_group_result(target_group) + end + + def row_move_index(move_to, current_index:, size:) + case move_to + when :highest + 0 + when :higher + [current_index - 1, 0].max + when :lower + [current_index + 1, size - 1].min + when :lowest + size - 1 + else + current_index + end + end + + def inactive_target?(target_id) + target_id.to_s == INACTIVE_TARGET + end + + def drop_row_to_inactive(row) + remove_row_from_source(row) + persist_group_result(row&.dig(:group)) + end + + def move_requested? + params[:move_to].present? + end + + def drop_requested? + params[:target_id].present? + end + + def update_row_position(row, move_to) + attributes = row[:group].attributes.dup + current_index = row[:index] + new_index = row_move_index(move_to, current_index:, size: attributes.length) + + attributes.insert(new_index, attributes.delete_at(current_index)) if new_index != current_index + row[:group].attributes = attributes + end + + def move_row_to_target_group(row, target_group, position) + remove_row_from_source(row) + target_attributes = target_group.attributes.dup + target_attributes.insert(drop_insert_position(position, target_attributes), @row_key) + target_group.attributes = target_attributes + end + + def persist_group_result(group) + persist_groups(active_groups).tap do |call| + call.result = group if call.success? + end + end + + def remove_row_from_source(row) + return unless row + + source_attributes = row[:group].attributes.dup + source_attributes.delete_at(row[:index]) + row[:group].attributes = source_attributes + end + + def drop_insert_position(position, target_attributes) + [position.to_i - 1, 0].max.clamp(0, target_attributes.length) + end + end + end +end diff --git a/app/services/work_package_types/update_service.rb b/app/services/work_package_types/update_service.rb index cc98f7a8095..36ac8e82429 100644 --- a/app/services/work_package_types/update_service.rb +++ b/app/services/work_package_types/update_service.rb @@ -36,7 +36,11 @@ module WorkPackageTypes def validate_params # Only set attribute groups when it exists (Regression #28400) - set_attribute_groups(params) if params[:attribute_groups] + if params[:attribute_groups] + result = set_attribute_groups(params) + return result if result.failure? + end + set_active_custom_fields set_active_custom_fields_for_project_ids(params[:project_ids]) if params[:project_ids].present? @@ -48,14 +52,49 @@ module WorkPackageTypes def default_contract_class = UpdateSettingsContract def set_attribute_groups(params) - if params[:attribute_groups].empty? + normalize_result = normalize_attribute_groups_param(params[:attribute_groups]) + return normalize_result if normalize_result.failure? + + assign_result = assign_attribute_groups(normalize_result.result) + return assign_result if assign_result.failure? + + params.delete(:attribute_groups) + ServiceResult.success(result: model) + end + + def normalize_attribute_groups_param(attribute_groups) + parsed_groups = case attribute_groups + when String + JSON.parse(attribute_groups) + else + attribute_groups + end + + return invalid_attribute_groups_result unless parsed_groups.is_a?(Array) + + ServiceResult.success(result: parsed_groups.map(&:deep_symbolize_keys)) + rescue JSON::ParserError + invalid_attribute_groups_result + end + + def assign_attribute_groups(attribute_groups) + if attribute_groups.empty? model.reset_attribute_groups else - # FIXME: We lost the ability to react to errors on the transformation. Might not be a big issue on day to day, but still - # a regression - 2025-08-07 noted by @mereghost - model.attribute_groups = AttributeGroups::Transformer.new(groups: params[:attribute_groups], user: user).call - params.delete(:attribute_groups) + transform_result = AttributeGroups::Transformer.new(groups: attribute_groups, user: user).call + return transform_result if transform_result.failure? + + model.attribute_groups = transform_result.result end + + ServiceResult.success(result: model) + end + + def invalid_attribute_groups_result + model.errors.clear + model.errors.add(:attribute_groups, I18n.t("types.edit.form_configuration.invalid_attribute_groups")) + + ServiceResult.failure(result: model, errors: model.errors) end ## diff --git a/app/views/custom_styles/_primer_color_mapping.erb b/app/views/custom_styles/_primer_color_mapping.erb index 37ad04283f5..f828167737f 100644 --- a/app/views/custom_styles/_primer_color_mapping.erb +++ b/app/views/custom_styles/_primer_color_mapping.erb @@ -83,7 +83,6 @@ --primary-button-color: var(--primary-button-color--dark-mode); --button--primary-background-disabled-color: var(--primary-button-color--major1); --button--primary-border-disabled-color: var(--primary-button-color--major1); - --type-form-conf-attribute--background: var(--overlay-bgColor); --select-arrow-bg-color-url: url("data:image/svg+xml;utf8,"); --ck-color-mention-text: var(--display-red-fgColor); --ck-color-button-on-background: var(--button-default-bgColor-active); diff --git a/app/views/work_package_types/form_configuration_tab/_form.html.erb b/app/views/work_package_types/form_configuration_tab/_form.html.erb deleted file mode 100644 index 54232eea089..00000000000 --- a/app/views/work_package_types/form_configuration_tab/_form.html.erb +++ /dev/null @@ -1,71 +0,0 @@ -<%#-- 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. - -++#%> - -<% form_attributes = form_configuration_groups(@type) %> - -
-
-
-
- <%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %> - - <% unless EnterpriseToken.allows_to?(:edit_attribute_groups) %> - <%= angular_component_tag "opce-no-results", - inputs: { - title: t("text_form_configuration") + t("text_custom_field_hint_activate_per_project") - } %> - <% end %> -
-
-
-
- - <% no_filter_query = ::API::V3::Queries::QueryParamsRepresenter.new(Query.new_default.tap { |q| q.filters = [] }).to_json %> - <%= f.hidden_field :attribute_groups, value: "", class: "admin-type-form--hidden-field" %> - <%= content_tag( - "opce-admin-type-form-configuration", - "", - data: { - "active-groups": form_attributes[:actives], - "inactive-attributes": form_attributes[:inactives], - "no-filter-query": no_filter_query - } - ) %> -
-
-
-
- -
-
- <%= styled_button_tag t(@type.new_record? ? :button_create : :button_save), - data: { turbo_submits_with: t(@type.new_record? ? :button_create : :button_save) }, - class: "form-configuration--save -primary -with-icon icon-checkmark" %> -
-
diff --git a/app/views/work_package_types/form_configuration_tab/edit.html.erb b/app/views/work_package_types/form_configuration_tab/edit.html.erb index d523df84aff..d477197c93e 100644 --- a/app/views/work_package_types/form_configuration_tab/edit.html.erb +++ b/app/views/work_package_types/form_configuration_tab/edit.html.erb @@ -31,13 +31,19 @@ See COPYRIGHT and LICENSE files for more details. <%= render ::Types::EditPageHeaderComponent.new(type: @type, tabs: types_tabs) %> -<%= - primer_form_with( - model: @type, - url: type_form_configuration_path(@type), - builder: TabularFormBuilder, - lang: current_language - ) do |f| - render partial: "form", locals: { f: f } - end -%> +<%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %> + +<% unless EnterpriseToken.allows_to?(:edit_attribute_groups) %> + <%= render(Primer::Alpha::Banner.new(scheme: :default, icon: :info, mb: 3)) do %> + <%= t("text_form_configuration") %> <%= t("text_custom_field_hint_activate_per_project") %> + <% end %> +<% end %> + +<% no_filter_query = ::API::V3::Queries::QueryParamsRepresenter.new(Query.new_default.tap { |q| q.filters = [] }).to_json %> +<%= render( + WorkPackageTypes::FormConfigurationComponent.new( + type: @type, + form_attributes: form_configuration_groups(@type), + no_filter_query: + ) + ) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 23f6b13c18a..46c674d2453 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1407,6 +1407,36 @@ en: edit: form_configuration: tab: "Form configuration" + label_group: "Group" + reset_to_defaults: "Reset to defaults" + add_attribute_group: "Add attribute group" + add_query_group: "Add table of related work packages" + delete_group: "Delete group" + remove_attribute: "Remove from group" + drag_to_activate: "Drag fields from here to activate them" + drag_to_reorder: "Drag to reorder" + edit_query: "Edit query" + custom_field: "Custom field" + filter_inactive: "Filter attributes" + inactive_attributes_heading: "Inactive attributes" + no_inactive_attributes: "No inactive attributes" + blankslate_title: "No groups yet" + blankslate_description: "Add groups using the button above or drag attributes from the left panel." + group_actions: "Group actions" + rename_group: "Rename group" + confirm_delete_group: "Are you sure you want to delete this group? This action cannot be automatically reversed." + group_name_label: "Group name" + row_actions: "Row actions" + query_group_label: "Work packages table" + empty_group_hint: "Drag attributes here" + invalid_attribute_groups: "The form configuration payload is invalid." + invalid_query: "The embedded query configuration is invalid." + not_found: "The requested form configuration item could not be found." + untitled_group: "Untitled group" + reset_title: "Reset form configuration" + confirm_reset: "Are you sure you want to reset the form configuration?" + reset_description: > + This will reset the attributes to their default group and disable ALL custom fields. projects: tab: Projects enable_all: Enable for all projects @@ -2503,7 +2533,7 @@ en: attribute_unknown_name: "Invalid work package attribute used: %{attribute}" duplicate_group: "The group name '%{group}' is used more than once. Group names must be unique." query_invalid: "The embedded query '%{group}' is invalid: %{details}" - group_without_name: "Unnamed groups are not allowed." + group_without_name: "Group name can't be blank." patterns: invalid_tokens: "One or more attributes inside the field are not valid. Please, fix the attributes before saving." user: diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 50545ecb125..ec5086e25d0 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -218,16 +218,7 @@ en: admin: type_form: - custom_field: "Custom field" - inactive: "Inactive" - drag_to_activate: "Drag fields from here to activate them" - add_group: "Add attribute group" - add_table: "Add table of related work packages" edit_query: "Edit query" - new_group: "New group" - delete_group: "Delete group" - remove_attribute: "Remove from group" - reset_to_defaults: "Reset to defaults" working_days: calendar: @@ -658,19 +649,6 @@ en: text_data_lost: "All entered data will be lost." text_user_wrote: "%{value} wrote:" - types: - attribute_groups: - error_duplicate_group_name: "The name %{group} is used more than once. Group names must be unique." - error_no_table_configured: "Please configure a table for %{group}." - reset_title: "Reset form configuration" - confirm_reset: > - Warning: Are you sure you want to reset the form configuration? - This will reset the attributes to their default group and disable ALL custom fields. - upgrade_to_ee: "Upgrade to Enterprise on-premises edition" - upgrade_to_ee_text: "Wow! If you need this add-on you are a super pro! Would you mind supporting us OpenSource developers by becoming an Enterprise edition client?" - more_information: "More information" - nevermind: "Nevermind" - time_entry: work_package_required: "Requires selecting a work package first." title: "Log time" diff --git a/config/routes.rb b/config/routes.rb index 0b53becfc63..1f084d956f8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -149,7 +149,27 @@ Rails.application.routes.draw do get "/roles/workflow/:id/:role_id/:type_id" => "roles#workflow" resources :types, module: "work_package_types", except: [:update] do - resource :form_configuration, only: %i[edit update], controller: "form_configuration_tab" + resource :form_configuration, only: %i[edit update], controller: "form_configuration_tab" do + get :reset_dialog + resources :groups, only: %i[create edit update destroy], controller: "form_configuration_groups_tab", param: :key do + collection do + post :add_group + end + + member do + post :cancel_edit + put :drop + put :move + patch :update_query + end + end + resources :rows, only: %i[destroy], controller: "form_configuration_tab", param: :row_key do + member do + put :drop + put :move + end + end + end resource :projects, controller: "projects_tab", only: %i[update edit] do collection do post :enable_all, to: "projects_tab#enable_all_projects" diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 1bca3a907a7..b3de94b03e8 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -181,7 +181,6 @@ import { OpNonWorkingDaysListComponent, } from 'core-app/shared/components/op-non-working-days-list/op-non-working-days-list.component'; import { PersistentToggleComponent } from 'core-app/shared/components/persistent-toggle/persistent-toggle.component'; -import { TypeFormConfigurationComponent } from 'core-app/features/admin/types/type-form-configuration.component'; import { ToastsContainerComponent } from 'core-app/shared/components/toaster/toasts-container.component'; import { GlobalSearchWorkPackagesComponent } from 'core-app/core/global_search/global-search-work-packages.component'; import { @@ -423,7 +422,6 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-non-working-days-list', OpNonWorkingDaysListComponent, { injector }); registerCustomElement('opce-main-menu-resizer', MainMenuResizerComponent, { injector }); registerCustomElement('opce-persistent-toggle', PersistentToggleComponent, { injector }); - registerCustomElement('opce-admin-type-form-configuration', TypeFormConfigurationComponent, { injector }); registerCustomElement('opce-toasts-container', ToastsContainerComponent, { injector }); registerCustomElement('opce-global-search-work-packages', GlobalSearchWorkPackagesComponent, { injector }); registerCustomElement('opce-custom-date-action-admin', CustomDateActionAdminComponent, { injector }); diff --git a/frontend/src/app/features/admin/openproject-admin.module.ts b/frontend/src/app/features/admin/openproject-admin.module.ts index db7c1865fb0..1406b31c49f 100644 --- a/frontend/src/app/features/admin/openproject-admin.module.ts +++ b/frontend/src/app/features/admin/openproject-admin.module.ts @@ -28,25 +28,15 @@ import { NgModule } from '@angular/core'; import { OpSharedModule } from 'core-app/shared/shared.module'; -import { DragulaModule } from 'ng2-dragula'; -import { TypeFormAttributeGroupComponent } from 'core-app/features/admin/types/attribute-group.component'; -import { TypeFormConfigurationComponent } from 'core-app/features/admin/types/type-form-configuration.component'; -import { TypeFormQueryGroupComponent } from 'core-app/features/admin/types/query-group.component'; -import { GroupEditInPlaceComponent } from 'core-app/features/admin/types/group-edit-in-place.component'; import { EditableQueryPropsComponent } from 'core-app/features/admin/editable-query-props/editable-query-props.component'; @NgModule({ imports: [ - DragulaModule.forRoot(), OpSharedModule, ], providers: [ ], declarations: [ - TypeFormAttributeGroupComponent, - TypeFormQueryGroupComponent, - TypeFormConfigurationComponent, - GroupEditInPlaceComponent, EditableQueryPropsComponent, ], }) diff --git a/frontend/src/app/features/admin/types/attribute-group.component.html b/frontend/src/app/features/admin/types/attribute-group.component.html deleted file mode 100644 index db088a299e8..00000000000 --- a/frontend/src/app/features/admin/types/attribute-group.component.html +++ /dev/null @@ -1,43 +0,0 @@ -
-
- - - -
-
- @for (attribute of group.attributes; track attribute) { -
- - - {{ attribute.translation }} - @if (attribute.is_cf) { - - - } - - -
- } -
-
diff --git a/frontend/src/app/features/admin/types/attribute-group.component.ts b/frontend/src/app/features/admin/types/attribute-group.component.ts deleted file mode 100644 index 69d2aa29828..00000000000 --- a/frontend/src/app/features/admin/types/attribute-group.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { TypeFormAttribute, TypeGroup } from 'core-app/features/admin/types/type-form-configuration.component'; - -@Component({ - selector: 'op-type-form-attribute-group', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './attribute-group.component.html', - standalone: false, -}) -export class TypeFormAttributeGroupComponent { - @Input() public group:TypeGroup; - - @Output() public deleteGroup = new EventEmitter(); - - @Output() public removeAttribute = new EventEmitter(); - - text = { - custom_field: this.I18n.t('js.admin.type_form.custom_field'), - delete_group: this.I18n.t('js.admin.type_form.delete_group'), - remove_attribute: this.I18n.t('js.admin.type_form.remove_attribute') - }; - - constructor(private I18n:I18nService, - private cdRef:ChangeDetectorRef) { - } - - rename(newValue:string) { - this.group.name = newValue; - delete this.group.key; - this.cdRef.detectChanges(); - } - - removeFromGroup(attribute:TypeFormAttribute) { - this.group.attributes = this.group.attributes.filter((a) => a !== attribute); - this.removeAttribute.emit(attribute); - } -} diff --git a/frontend/src/app/features/admin/types/group-edit-in-place.component.ts b/frontend/src/app/features/admin/types/group-edit-in-place.component.ts deleted file mode 100644 index fd0bcdad58d..00000000000 --- a/frontend/src/app/features/admin/types/group-edit-in-place.component.ts +++ /dev/null @@ -1,108 +0,0 @@ -//-- 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. -//++ - -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - Input, - OnInit, - Output, -} from '@angular/core'; -import { TypeBannerService } from 'core-app/features/admin/types/type-banner.service'; - -@Component({ - selector: 'op-group-edit-in-place', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './group-edit-in-place.html', - standalone: false, -}) -export class GroupEditInPlaceComponent implements OnInit { - @Input() public placeholder = ''; - - @Input() public name:string; - - @Output() public onValueChange = new EventEmitter(); - - public editing = false; - - public editedName:string; - - constructor(private bannerService:TypeBannerService, - protected readonly cdRef:ChangeDetectorRef) { - } - - ngOnInit():void { - this.editedName = this.name; - - if (!this.name || this.name.length === 0) { - // Group name is empty so open in editing mode straight away. - this.startEditing(); - } - } - - startEditing():void { - void this.bannerService.conditional( - 'edit_attribute_groups', - () => this.bannerService.showEEOnlyHint(), - () => { - this.editing = true; - this.cdRef.detectChanges(); - }, - ); - } - - saveEdition(event:FocusEvent):boolean { - this.leaveEditingMode(); - this.name = this.editedName.trim(); - - this.cdRef.detectChanges(); - - if (this.name !== '') { - this.onValueChange.emit(this.name); - } - - // Ensure form is not submitted. - event.preventDefault(); - event.stopPropagation(); - return false; - } - - reset():void { - this.editing = false; - this.editedName = this.name; - } - - leaveEditingMode():void { - // Only leave Editing mode if name not empty. - if (this.editedName != null && this.editedName.trim().length > 0) { - this.editing = false; - } - } -} diff --git a/frontend/src/app/features/admin/types/group-edit-in-place.html b/frontend/src/app/features/admin/types/group-edit-in-place.html deleted file mode 100644 index 6049b310fd3..00000000000 --- a/frontend/src/app/features/admin/types/group-edit-in-place.html +++ /dev/null @@ -1,23 +0,0 @@ -@if (!editing) { -
-
-} -@if (editing) { - -} diff --git a/frontend/src/app/features/admin/types/query-group.component.html b/frontend/src/app/features/admin/types/query-group.component.html deleted file mode 100644 index 47e6afddaa3..00000000000 --- a/frontend/src/app/features/admin/types/query-group.component.html +++ /dev/null @@ -1,28 +0,0 @@ -
-
- - - -
-
- - - {{ text.edit_query }} - -
-
diff --git a/frontend/src/app/features/admin/types/query-group.component.ts b/frontend/src/app/features/admin/types/query-group.component.ts deleted file mode 100644 index b88cc3f8327..00000000000 --- a/frontend/src/app/features/admin/types/query-group.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { TypeGroup } from 'core-app/features/admin/types/type-form-configuration.component'; - -@Component({ - selector: 'op-type-form-query-group', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './query-group.component.html', - standalone: false, -}) -export class TypeFormQueryGroupComponent { - text = { - edit_query: this.I18n.t('js.admin.type_form.edit_query'), - delete_group: this.I18n.t('js.admin.type_form.delete_group') - }; - - @Input() public group:TypeGroup; - - @Output() public editQuery = new EventEmitter(); - - @Output() public deleteGroup = new EventEmitter(); - - constructor(private I18n:I18nService, - private cdRef:ChangeDetectorRef) { - } - - rename(newValue:string):void { - this.group.name = newValue; - this.cdRef.detectChanges(); - } -} diff --git a/frontend/src/app/features/admin/types/type-banner.service.ts b/frontend/src/app/features/admin/types/type-banner.service.ts deleted file mode 100644 index 9878c8a155d..00000000000 --- a/frontend/src/app/features/admin/types/type-banner.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { BannersService } from 'core-app/core/enterprise/banners.service'; -import { Inject, Injectable, DOCUMENT } from '@angular/core'; -import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; - -@Injectable() -export class TypeBannerService extends BannersService { - eeAvailable = this.allowsTo('edit_attribute_groups'); - - constructor( - @Inject(DOCUMENT) protected documentElement:Document, - protected confirmDialog:ConfirmDialogService, - protected I18n:I18nService, - protected configuration:ConfigurationService, - ) { - super(documentElement, configuration); - } - - showEEOnlyHint():void { - this.confirmDialog.confirm({ - text: { - title: this.I18n.t('js.types.attribute_groups.upgrade_to_ee'), - text: this.I18n.t('js.types.attribute_groups.upgrade_to_ee_text'), - button_continue: this.I18n.t('js.types.attribute_groups.more_information'), - button_cancel: this.I18n.t('js.types.attribute_groups.nevermind'), - }, - }).then(() => { - window.location.href = 'https://www.openproject.org/enterprise-edition/?utm_source=unknown&utm_medium=community-edition&utm_campaign=form-configuration'; - }) - .catch(() => { - }); - } -} diff --git a/frontend/src/app/features/admin/types/type-form-configuration.component.ts b/frontend/src/app/features/admin/types/type-form-configuration.component.ts deleted file mode 100644 index f361ec1c45c..00000000000 --- a/frontend/src/app/features/admin/types/type-form-configuration.component.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - ElementRef, - OnDestroy, - OnInit -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { - ExternalRelationQueryConfigurationService, -} from 'core-app/features/work-packages/components/wp-table/external-configuration/external-relation-query-configuration.service'; -import { DomAutoscrollService } from 'core-app/shared/helpers/drag-and-drop/dom-autoscroll.service'; -import { DragulaService, DrakeWithModels } from 'ng2-dragula'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { installMenuLogic } from 'core-app/core/setup/globals/global-listeners/action-menu'; -import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service'; -import { TypeBannerService } from 'core-app/features/admin/types/type-banner.service'; - -export type TypeGroupType = 'attribute'|'query'; - -export interface TypeFormAttribute { - key:string; - translation:string; - is_cf:boolean; -} - -export interface TypeGroup { - /** original internal key, if any */ - key:string|null|undefined; - /** Localized / given name */ - name:string; - attributes:TypeFormAttribute[]; - query?:any; - type:TypeGroupType; -} - -export const emptyTypeGroup = '__empty'; - -@Component({ - selector: 'opce-admin-type-form-configuration', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './type-form-configuration.html', - providers: [ - TypeBannerService, - DragulaService - ], - standalone: false, -}) -export class TypeFormConfigurationComponent extends UntilDestroyedMixin implements OnInit, AfterViewInit, OnDestroy { - public text = { - drag_to_activate: this.I18n.t('js.admin.type_form.drag_to_activate'), - reset: this.I18n.t('js.admin.type_form.reset_to_defaults'), - label_group: this.I18n.t('js.label_group'), - new_group: this.I18n.t('js.admin.type_form.new_group'), - label_inactive: this.I18n.t('js.admin.type_form.inactive'), - custom_field: this.I18n.t('js.admin.type_form.custom_field'), - add_group: this.I18n.t('js.admin.type_form.add_group'), - add_table: this.I18n.t('js.admin.type_form.add_table'), - }; - - private autoscroll:any; - - private element:HTMLElement; - - private form:HTMLFormElement; - - private submit:HTMLButtonElement; - - public groups:TypeGroup[] = []; - - public inactives:TypeFormAttribute[] = []; - - private attributeDrake:DrakeWithModels; - - private groupsDrake:DrakeWithModels; - - private no_filter_query:string; - - private eventListeners = { - typeFormUpdater: () => { - this.updateHiddenFields(); - } - }; - - constructor( - private elementRef:ElementRef, - private I18n:I18nService, - private dragula:DragulaService, - private confirmDialog:ConfirmDialogService, - private externalRelationQuery:ExternalRelationQueryConfigurationService, - readonly typeBanner:TypeBannerService, - ) { - super(); - } - - ngOnInit():void { - // For unclear reasons, this component is initialized twice if used in conjunction with - // turbo drive. This then leads to the groups defined in this component being duplicated. - // It does not harm to remove them if they exist but it would of course be better if that hack - // would not be necessary. The functionality should all be handled in ngOnDestroy. - this.dragula.destroy('groups'); - this.dragula.destroy('attributes'); - - // Hook on form submit - this.element = this.elementRef.nativeElement; - this.no_filter_query = this.element.dataset.noFilterQuery!; - this.form = this.element.closest('form')!; - this.submit = this.form.querySelector('.form-configuration--save')!; - - // Capture regular form submit - this.form.addEventListener('submit', this.eventListeners.typeFormUpdater); - - // Setup groups - this.groupsDrake = this - .dragula - .createGroup('groups', { - moves: (el, source, handle:HTMLElement) => handle.classList.contains('group-handle'), - }) - .drake; - - // Setup attributes - this.attributeDrake = this - .dragula - .createGroup('attributes', { - moves: (el, source, handle:HTMLElement) => handle.classList.contains('attribute-handle'), - }) - .drake; - - // Get attribute id - this.groups = JSON - .parse(this.element.dataset.activeGroups!) - .filter((group:TypeGroup) => group?.key !== emptyTypeGroup); - this.inactives = JSON.parse(this.element.dataset.inactiveAttributes!); - - // Setup autoscroll - const that = this; - this.autoscroll = new DomAutoscrollService( - [ - document.getElementById('content-body')!, - ], - { - margin: 25, - maxSpeed: 10, - scrollWhenOutside: true, - autoScroll(this:any) { - const groups = that.groupsDrake && that.groupsDrake.dragging; - const attributes = that.attributeDrake && that.attributeDrake.dragging; - - return groups || attributes; - }, - }, - ); - } - - ngAfterViewInit():void { - const menu = this.elementRef.nativeElement.querySelector('.toolbar-items')!; - installMenuLogic(menu); - } - - ngOnDestroy():void { - this.dragula.destroy('groups'); - this.dragula.destroy('attributes'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call - this.autoscroll.destroy(); - } - - deactivateAttribute(attribute:TypeFormAttribute):void { - this.updateInactives(this.inactives.concat(attribute)); - } - - addGroupAndOpenQuery():void { - const newGroup = this.createGroup('query'); - this.editQuery(newGroup); - } - - editQuery(group:TypeGroup):void { - void this.typeBanner.conditional( - 'edit_attribute_groups', - () => this.typeBanner.showEEOnlyHint(), - () => { - // Disable display mode and timeline for now since we don't want users to enable it - const disabledTabs = { - 'display-settings': I18n.t('js.work_packages.table_configuration.embedded_tab_disabled'), - timelines: I18n.t('js.work_packages.table_configuration.embedded_tab_disabled'), - }; - - this.externalRelationQuery.show({ - currentQuery: JSON.parse(group.query), - callback: (queryProps:any) => (group.query = JSON.stringify(queryProps)), - disabledTabs, - }); - }, - ); - } - - deleteGroup(group:TypeGroup):void { - void this.typeBanner.conditional( - 'edit_attribute_groups', - () => this.typeBanner.showEEOnlyHint(), - () => { - if (group.type === 'attribute') { - this.updateInactives(this.inactives.concat(group.attributes)); - } - - this.groups = this.groups.filter((el) => el !== group); - - return group; - }, - ); - } - - createGroup(type:TypeGroupType, groupName = ''):TypeGroup { - const group:TypeGroup = { - type, - name: groupName, - key: null, - query: this.no_filter_query, - attributes: [], - }; - - this.groups.unshift(group); - return group; - } - - resetToDefault($event:Event):boolean { - this.confirmDialog - .confirm({ - text: { - title: this.I18n.t('js.types.attribute_groups.reset_title'), - text: this.I18n.t('js.types.attribute_groups.confirm_reset'), - button_continue: this.I18n.t('js.label_reset'), - }, - }) - .then(() => { - const input = this.form.querySelector('input#type_attribute_groups')!; - input.value = JSON.stringify([]); - - // Disable our form handler that updates the attribute groups - this.form.removeEventListener('submit', this.eventListeners.typeFormUpdater); - this.form.requestSubmit(); - }) - .catch(() => { - }); - - $event.preventDefault(); - return false; - } - - private updateInactives(newValue:TypeFormAttribute[]):void { - this.inactives = [...newValue].sort((a, b) => a.translation.localeCompare(b.translation)); - } - - // We maintain an empty group - // that gets hidden in the frontend in case the user - // decides to remove all groups - // This was necessary since the "default" is actually an empty array of groups - private get emptyGroup():TypeGroup { - return { - type: 'attribute', key: emptyTypeGroup, name: 'empty', attributes: [], - }; - } - - private updateHiddenFields():void { - const hiddenField = this.form.querySelector('.admin-type-form--hidden-field')!; - if (this.groups.length === 0) { - // Ensure we're adding an empty group if deliberately removing - // all values. - hiddenField.value = JSON.stringify([this.emptyGroup]); - } else { - hiddenField.value = JSON.stringify(this.groups); - } - } -} diff --git a/frontend/src/app/features/admin/types/type-form-configuration.html b/frontend/src/app/features/admin/types/type-form-configuration.html deleted file mode 100644 index 824d39d8eb6..00000000000 --- a/frontend/src/app/features/admin/types/type-form-configuration.html +++ /dev/null @@ -1,90 +0,0 @@ -
-
-
    -
  • - -
  • - @if (typeBanner.eeAvailable) { - - } -
-
-
- -
-
-
- @for (group of groups; track group) { - @if (group.type === 'attribute') { - - } - @if (group.type === 'query') { - - } - } -
-
-
-
-
- - &ngsp; - -
-
- @for (inactive_attribute of inactives; track inactive_attribute) { -
- - - {{ inactive_attribute.translation }} - @if (inactive_attribute.is_cf) { - - - } - -
- } -
-
-
-
diff --git a/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration-relation-selector.html b/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration-relation-selector.html index 4884b2d01a8..1b4f42fd1cb 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration-relation-selector.html +++ b/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration-relation-selector.html @@ -3,6 +3,7 @@ &ngsp;