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) %>
-
-