mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
[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>
This commit is contained in:
committed by
GitHub
parent
55ec857669
commit
d924c255cf
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
+74
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
+49
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
+43
@@ -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
|
||||
%>
|
||||
+67
@@ -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
|
||||
+42
@@ -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
|
||||
%>
|
||||
+43
@@ -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
|
||||
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
##
|
||||
|
||||
@@ -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,<svg fill='white' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
|
||||
--ck-color-mention-text: var(--display-red-fgColor);
|
||||
--ck-color-button-on-background: var(--button-default-bgColor-active);
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
<section class="form--section">
|
||||
<div>
|
||||
<div class="grid-block -visible-overflow wrap">
|
||||
<div class="grid-content -visible-overflow small-12 large-10">
|
||||
<%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %>
|
||||
|
||||
<% unless EnterpriseToken.allows_to?(:edit_attribute_groups) %>
|
||||
<%= angular_component_tag "opce-no-results",
|
||||
inputs: {
|
||||
title: t("text_form_configuration") + t("text_custom_field_hint_activate_per_project")
|
||||
} %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-block -visible-overflow wrap">
|
||||
<div class="grid-content -visible-overflow small-12 large-10">
|
||||
|
||||
<% no_filter_query = ::API::V3::Queries::QueryParamsRepresenter.new(Query.new_default.tap { |q| q.filters = [] }).to_json %>
|
||||
<%= f.hidden_field :attribute_groups, value: "", class: "admin-type-form--hidden-field" %>
|
||||
<%= content_tag(
|
||||
"opce-admin-type-form-configuration",
|
||||
"",
|
||||
data: {
|
||||
"active-groups": form_attributes[:actives],
|
||||
"inactive-attributes": form_attributes[:inactives],
|
||||
"no-filter-query": no_filter_query
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid-block">
|
||||
<div class="generic-table--action-buttons">
|
||||
<%= styled_button_tag t(@type.new_record? ? :button_create : :button_save),
|
||||
data: { turbo_submits_with: t(@type.new_record? ? :button_create : :button_save) },
|
||||
class: "form-configuration--save -primary -with-icon icon-checkmark" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,13 +31,19 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
|
||||
<%= render ::Types::EditPageHeaderComponent.new(type: @type, tabs: types_tabs) %>
|
||||
|
||||
<%=
|
||||
primer_form_with(
|
||||
model: @type,
|
||||
url: type_form_configuration_path(@type),
|
||||
builder: TabularFormBuilder,
|
||||
lang: current_language
|
||||
) do |f|
|
||||
render partial: "form", locals: { f: f }
|
||||
end
|
||||
%>
|
||||
<%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %>
|
||||
|
||||
<% unless EnterpriseToken.allows_to?(:edit_attribute_groups) %>
|
||||
<%= render(Primer::Alpha::Banner.new(scheme: :default, icon: :info, mb: 3)) do %>
|
||||
<%= t("text_form_configuration") %> <%= t("text_custom_field_hint_activate_per_project") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% no_filter_query = ::API::V3::Queries::QueryParamsRepresenter.new(Query.new_default.tap { |q| q.filters = [] }).to_json %>
|
||||
<%= render(
|
||||
WorkPackageTypes::FormConfigurationComponent.new(
|
||||
type: @type,
|
||||
form_attributes: form_configuration_groups(@type),
|
||||
no_filter_query:
|
||||
)
|
||||
) %>
|
||||
|
||||
+31
-1
@@ -1407,6 +1407,36 @@ en:
|
||||
edit:
|
||||
form_configuration:
|
||||
tab: "Form configuration"
|
||||
label_group: "Group"
|
||||
reset_to_defaults: "Reset to defaults"
|
||||
add_attribute_group: "Add attribute group"
|
||||
add_query_group: "Add table of related work packages"
|
||||
delete_group: "Delete group"
|
||||
remove_attribute: "Remove from group"
|
||||
drag_to_activate: "Drag fields from here to activate them"
|
||||
drag_to_reorder: "Drag to reorder"
|
||||
edit_query: "Edit query"
|
||||
custom_field: "Custom field"
|
||||
filter_inactive: "Filter attributes"
|
||||
inactive_attributes_heading: "Inactive attributes"
|
||||
no_inactive_attributes: "No inactive attributes"
|
||||
blankslate_title: "No groups yet"
|
||||
blankslate_description: "Add groups using the button above or drag attributes from the left panel."
|
||||
group_actions: "Group actions"
|
||||
rename_group: "Rename group"
|
||||
confirm_delete_group: "Are you sure you want to delete this group? This action cannot be automatically reversed."
|
||||
group_name_label: "Group name"
|
||||
row_actions: "Row actions"
|
||||
query_group_label: "Work packages table"
|
||||
empty_group_hint: "Drag attributes here"
|
||||
invalid_attribute_groups: "The form configuration payload is invalid."
|
||||
invalid_query: "The embedded query configuration is invalid."
|
||||
not_found: "The requested form configuration item could not be found."
|
||||
untitled_group: "Untitled group"
|
||||
reset_title: "Reset form configuration"
|
||||
confirm_reset: "Are you sure you want to reset the form configuration?"
|
||||
reset_description: >
|
||||
This will reset the attributes to their default group and disable ALL custom fields.
|
||||
projects:
|
||||
tab: Projects
|
||||
enable_all: Enable for all projects
|
||||
@@ -2503,7 +2533,7 @@ en:
|
||||
attribute_unknown_name: "Invalid work package attribute used: %{attribute}"
|
||||
duplicate_group: "The group name '%{group}' is used more than once. Group names must be unique."
|
||||
query_invalid: "The embedded query '%{group}' is invalid: %{details}"
|
||||
group_without_name: "Unnamed groups are not allowed."
|
||||
group_without_name: "Group name can't be blank."
|
||||
patterns:
|
||||
invalid_tokens: "One or more attributes inside the field are not valid. Please, fix the attributes before saving."
|
||||
user:
|
||||
|
||||
@@ -218,16 +218,7 @@ en:
|
||||
|
||||
admin:
|
||||
type_form:
|
||||
custom_field: "Custom field"
|
||||
inactive: "Inactive"
|
||||
drag_to_activate: "Drag fields from here to activate them"
|
||||
add_group: "Add attribute group"
|
||||
add_table: "Add table of related work packages"
|
||||
edit_query: "Edit query"
|
||||
new_group: "New group"
|
||||
delete_group: "Delete group"
|
||||
remove_attribute: "Remove from group"
|
||||
reset_to_defaults: "Reset to defaults"
|
||||
|
||||
working_days:
|
||||
calendar:
|
||||
@@ -658,19 +649,6 @@ en:
|
||||
text_data_lost: "All entered data will be lost."
|
||||
text_user_wrote: "%{value} wrote:"
|
||||
|
||||
types:
|
||||
attribute_groups:
|
||||
error_duplicate_group_name: "The name %{group} is used more than once. Group names must be unique."
|
||||
error_no_table_configured: "Please configure a table for %{group}."
|
||||
reset_title: "Reset form configuration"
|
||||
confirm_reset: >
|
||||
Warning: Are you sure you want to reset the form configuration?
|
||||
This will reset the attributes to their default group and disable ALL custom fields.
|
||||
upgrade_to_ee: "Upgrade to Enterprise on-premises edition"
|
||||
upgrade_to_ee_text: "Wow! If you need this add-on you are a super pro! Would you mind supporting us OpenSource developers by becoming an Enterprise edition client?"
|
||||
more_information: "More information"
|
||||
nevermind: "Nevermind"
|
||||
|
||||
time_entry:
|
||||
work_package_required: "Requires selecting a work package first."
|
||||
title: "Log time"
|
||||
|
||||
+21
-1
@@ -149,7 +149,27 @@ Rails.application.routes.draw do
|
||||
get "/roles/workflow/:id/:role_id/:type_id" => "roles#workflow"
|
||||
|
||||
resources :types, module: "work_package_types", except: [:update] do
|
||||
resource :form_configuration, only: %i[edit update], controller: "form_configuration_tab"
|
||||
resource :form_configuration, only: %i[edit update], controller: "form_configuration_tab" do
|
||||
get :reset_dialog
|
||||
resources :groups, only: %i[create edit update destroy], controller: "form_configuration_groups_tab", param: :key do
|
||||
collection do
|
||||
post :add_group
|
||||
end
|
||||
|
||||
member do
|
||||
post :cancel_edit
|
||||
put :drop
|
||||
put :move
|
||||
patch :update_query
|
||||
end
|
||||
end
|
||||
resources :rows, only: %i[destroy], controller: "form_configuration_tab", param: :row_key do
|
||||
member do
|
||||
put :drop
|
||||
put :move
|
||||
end
|
||||
end
|
||||
end
|
||||
resource :projects, controller: "projects_tab", only: %i[update edit] do
|
||||
collection do
|
||||
post :enable_all, to: "projects_tab#enable_all_projects"
|
||||
|
||||
@@ -181,7 +181,6 @@ import {
|
||||
OpNonWorkingDaysListComponent,
|
||||
} from 'core-app/shared/components/op-non-working-days-list/op-non-working-days-list.component';
|
||||
import { PersistentToggleComponent } from 'core-app/shared/components/persistent-toggle/persistent-toggle.component';
|
||||
import { TypeFormConfigurationComponent } from 'core-app/features/admin/types/type-form-configuration.component';
|
||||
import { ToastsContainerComponent } from 'core-app/shared/components/toaster/toasts-container.component';
|
||||
import { GlobalSearchWorkPackagesComponent } from 'core-app/core/global_search/global-search-work-packages.component';
|
||||
import {
|
||||
@@ -423,7 +422,6 @@ export class OpenProjectModule implements DoBootstrap {
|
||||
registerCustomElement('opce-non-working-days-list', OpNonWorkingDaysListComponent, { injector });
|
||||
registerCustomElement('opce-main-menu-resizer', MainMenuResizerComponent, { injector });
|
||||
registerCustomElement('opce-persistent-toggle', PersistentToggleComponent, { injector });
|
||||
registerCustomElement('opce-admin-type-form-configuration', TypeFormConfigurationComponent, { injector });
|
||||
registerCustomElement('opce-toasts-container', ToastsContainerComponent, { injector });
|
||||
registerCustomElement('opce-global-search-work-packages', GlobalSearchWorkPackagesComponent, { injector });
|
||||
registerCustomElement('opce-custom-date-action-admin', CustomDateActionAdminComponent, { injector });
|
||||
|
||||
@@ -28,25 +28,15 @@
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { OpSharedModule } from 'core-app/shared/shared.module';
|
||||
import { DragulaModule } from 'ng2-dragula';
|
||||
import { TypeFormAttributeGroupComponent } from 'core-app/features/admin/types/attribute-group.component';
|
||||
import { TypeFormConfigurationComponent } from 'core-app/features/admin/types/type-form-configuration.component';
|
||||
import { TypeFormQueryGroupComponent } from 'core-app/features/admin/types/query-group.component';
|
||||
import { GroupEditInPlaceComponent } from 'core-app/features/admin/types/group-edit-in-place.component';
|
||||
import { EditableQueryPropsComponent } from 'core-app/features/admin/editable-query-props/editable-query-props.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
DragulaModule.forRoot(),
|
||||
OpSharedModule,
|
||||
],
|
||||
providers: [
|
||||
],
|
||||
declarations: [
|
||||
TypeFormAttributeGroupComponent,
|
||||
TypeFormQueryGroupComponent,
|
||||
TypeFormConfigurationComponent,
|
||||
GroupEditInPlaceComponent,
|
||||
EditableQueryPropsComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<div class="type-form-conf-group">
|
||||
<div class="group-head">
|
||||
<span class="group-handle icon-drag-handle"></span>
|
||||
<op-group-edit-in-place [name]="group.name"
|
||||
(onValueChange)="rename($event)"
|
||||
class="group-name" />
|
||||
<primer-icon-button
|
||||
icon="x"
|
||||
scheme="invisible"
|
||||
size="small"
|
||||
class="delete-group"
|
||||
tooltip-direction="w"
|
||||
[label]="text.delete_group"
|
||||
(clicked)="deleteGroup.emit()"
|
||||
/>
|
||||
</div>
|
||||
<div class="attributes" dragula="attributes" [(dragulaModel)]="group.attributes">
|
||||
@for (attribute of group.attributes; track attribute) {
|
||||
<div
|
||||
class="type-form-conf-attribute"
|
||||
[attr.data-key]="attribute.key">
|
||||
<span class="attribute-handle icon-drag-handle"></span>
|
||||
<span class="attribute-name">
|
||||
{{ attribute.translation }}
|
||||
@if (attribute.is_cf) {
|
||||
<span class="attribute-cf-label"
|
||||
[textContent]="text.custom_field">
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<primer-icon-button
|
||||
icon="x"
|
||||
scheme="invisible"
|
||||
size="small"
|
||||
class="delete-attribute"
|
||||
tooltip-direction="w"
|
||||
[label]="text.remove_attribute"
|
||||
(clicked)="removeFromGroup(attribute)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div> <!-- END attribute group -->
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { TypeFormAttribute, TypeGroup } from 'core-app/features/admin/types/type-form-configuration.component';
|
||||
|
||||
@Component({
|
||||
selector: 'op-type-form-attribute-group',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './attribute-group.component.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class TypeFormAttributeGroupComponent {
|
||||
@Input() public group:TypeGroup;
|
||||
|
||||
@Output() public deleteGroup = new EventEmitter<void>();
|
||||
|
||||
@Output() public removeAttribute = new EventEmitter<TypeFormAttribute>();
|
||||
|
||||
text = {
|
||||
custom_field: this.I18n.t('js.admin.type_form.custom_field'),
|
||||
delete_group: this.I18n.t('js.admin.type_form.delete_group'),
|
||||
remove_attribute: this.I18n.t('js.admin.type_form.remove_attribute')
|
||||
};
|
||||
|
||||
constructor(private I18n:I18nService,
|
||||
private cdRef:ChangeDetectorRef) {
|
||||
}
|
||||
|
||||
rename(newValue:string) {
|
||||
this.group.name = newValue;
|
||||
delete this.group.key;
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
removeFromGroup(attribute:TypeFormAttribute) {
|
||||
this.group.attributes = this.group.attributes.filter((a) => a !== attribute);
|
||||
this.removeAttribute.emit(attribute);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//-- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) the OpenProject GmbH
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { TypeBannerService } from 'core-app/features/admin/types/type-banner.service';
|
||||
|
||||
@Component({
|
||||
selector: 'op-group-edit-in-place',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './group-edit-in-place.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class GroupEditInPlaceComponent implements OnInit {
|
||||
@Input() public placeholder = '';
|
||||
|
||||
@Input() public name:string;
|
||||
|
||||
@Output() public onValueChange = new EventEmitter<string>();
|
||||
|
||||
public editing = false;
|
||||
|
||||
public editedName:string;
|
||||
|
||||
constructor(private bannerService:TypeBannerService,
|
||||
protected readonly cdRef:ChangeDetectorRef) {
|
||||
}
|
||||
|
||||
ngOnInit():void {
|
||||
this.editedName = this.name;
|
||||
|
||||
if (!this.name || this.name.length === 0) {
|
||||
// Group name is empty so open in editing mode straight away.
|
||||
this.startEditing();
|
||||
}
|
||||
}
|
||||
|
||||
startEditing():void {
|
||||
void this.bannerService.conditional(
|
||||
'edit_attribute_groups',
|
||||
() => this.bannerService.showEEOnlyHint(),
|
||||
() => {
|
||||
this.editing = true;
|
||||
this.cdRef.detectChanges();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
saveEdition(event:FocusEvent):boolean {
|
||||
this.leaveEditingMode();
|
||||
this.name = this.editedName.trim();
|
||||
|
||||
this.cdRef.detectChanges();
|
||||
|
||||
if (this.name !== '') {
|
||||
this.onValueChange.emit(this.name);
|
||||
}
|
||||
|
||||
// Ensure form is not submitted.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
reset():void {
|
||||
this.editing = false;
|
||||
this.editedName = this.name;
|
||||
}
|
||||
|
||||
leaveEditingMode():void {
|
||||
// Only leave Editing mode if name not empty.
|
||||
if (this.editedName != null && this.editedName.trim().length > 0) {
|
||||
this.editing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
@if (!editing) {
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group-edit-handler"
|
||||
[textContent]="name"
|
||||
(click)="startEditing()"
|
||||
(keydown.enter)="startEditing()"
|
||||
(keydown.space)="startEditing()">
|
||||
</div>
|
||||
}
|
||||
@if (editing) {
|
||||
<input
|
||||
#nameInput
|
||||
class="group-edit-in-place--input"
|
||||
type="text"
|
||||
opAutofocus
|
||||
[(ngModel)]="editedName"
|
||||
[attr.placeholder]="placeholder"
|
||||
(blur)="saveEdition($event)"
|
||||
(keydown.escape)="reset()"
|
||||
(keydown.enter)="saveEdition($event)">
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="type-form-conf-group type-form-query-group">
|
||||
<div class="group-head">
|
||||
<span class="group-handle icon-drag-handle"></span>
|
||||
<op-group-edit-in-place [name]="group.name"
|
||||
(onValueChange)="rename($event)"
|
||||
class="group-name" />
|
||||
<primer-icon-button
|
||||
icon="x"
|
||||
scheme="invisible"
|
||||
size="small"
|
||||
class="delete-group"
|
||||
tooltip-direction="w"
|
||||
[label]="text.delete_group"
|
||||
(clicked)="deleteGroup.emit()"
|
||||
/>
|
||||
</div>
|
||||
<div class="type-form-query">
|
||||
<span class="type-form-query-group--edit-button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="editQuery.emit()"
|
||||
(keydown.enter)="editQuery.emit()"
|
||||
(keydown.space)="editQuery.emit()">
|
||||
<op-icon icon-classes="button--icon icon-edit" />
|
||||
{{ text.edit_query }}
|
||||
</span>
|
||||
</div>
|
||||
</div> <!-- END query group -->
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { TypeGroup } from 'core-app/features/admin/types/type-form-configuration.component';
|
||||
|
||||
@Component({
|
||||
selector: 'op-type-form-query-group',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './query-group.component.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class TypeFormQueryGroupComponent {
|
||||
text = {
|
||||
edit_query: this.I18n.t('js.admin.type_form.edit_query'),
|
||||
delete_group: this.I18n.t('js.admin.type_form.delete_group')
|
||||
};
|
||||
|
||||
@Input() public group:TypeGroup;
|
||||
|
||||
@Output() public editQuery = new EventEmitter<void>();
|
||||
|
||||
@Output() public deleteGroup = new EventEmitter<void>();
|
||||
|
||||
constructor(private I18n:I18nService,
|
||||
private cdRef:ChangeDetectorRef) {
|
||||
}
|
||||
|
||||
rename(newValue:string):void {
|
||||
this.group.name = newValue;
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { BannersService } from 'core-app/core/enterprise/banners.service';
|
||||
import { Inject, Injectable, DOCUMENT } from '@angular/core';
|
||||
import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service';
|
||||
import { ConfigurationService } from 'core-app/core/config/configuration.service';
|
||||
|
||||
@Injectable()
|
||||
export class TypeBannerService extends BannersService {
|
||||
eeAvailable = this.allowsTo('edit_attribute_groups');
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) protected documentElement:Document,
|
||||
protected confirmDialog:ConfirmDialogService,
|
||||
protected I18n:I18nService,
|
||||
protected configuration:ConfigurationService,
|
||||
) {
|
||||
super(documentElement, configuration);
|
||||
}
|
||||
|
||||
showEEOnlyHint():void {
|
||||
this.confirmDialog.confirm({
|
||||
text: {
|
||||
title: this.I18n.t('js.types.attribute_groups.upgrade_to_ee'),
|
||||
text: this.I18n.t('js.types.attribute_groups.upgrade_to_ee_text'),
|
||||
button_continue: this.I18n.t('js.types.attribute_groups.more_information'),
|
||||
button_cancel: this.I18n.t('js.types.attribute_groups.nevermind'),
|
||||
},
|
||||
}).then(() => {
|
||||
window.location.href = 'https://www.openproject.org/enterprise-edition/?utm_source=unknown&utm_medium=community-edition&utm_campaign=form-configuration';
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import {
|
||||
ExternalRelationQueryConfigurationService,
|
||||
} from 'core-app/features/work-packages/components/wp-table/external-configuration/external-relation-query-configuration.service';
|
||||
import { DomAutoscrollService } from 'core-app/shared/helpers/drag-and-drop/dom-autoscroll.service';
|
||||
import { DragulaService, DrakeWithModels } from 'ng2-dragula';
|
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
|
||||
import { installMenuLogic } from 'core-app/core/setup/globals/global-listeners/action-menu';
|
||||
import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service';
|
||||
import { TypeBannerService } from 'core-app/features/admin/types/type-banner.service';
|
||||
|
||||
export type TypeGroupType = 'attribute'|'query';
|
||||
|
||||
export interface TypeFormAttribute {
|
||||
key:string;
|
||||
translation:string;
|
||||
is_cf:boolean;
|
||||
}
|
||||
|
||||
export interface TypeGroup {
|
||||
/** original internal key, if any */
|
||||
key:string|null|undefined;
|
||||
/** Localized / given name */
|
||||
name:string;
|
||||
attributes:TypeFormAttribute[];
|
||||
query?:any;
|
||||
type:TypeGroupType;
|
||||
}
|
||||
|
||||
export const emptyTypeGroup = '__empty';
|
||||
|
||||
@Component({
|
||||
selector: 'opce-admin-type-form-configuration',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './type-form-configuration.html',
|
||||
providers: [
|
||||
TypeBannerService,
|
||||
DragulaService
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
export class TypeFormConfigurationComponent extends UntilDestroyedMixin implements OnInit, AfterViewInit, OnDestroy {
|
||||
public text = {
|
||||
drag_to_activate: this.I18n.t('js.admin.type_form.drag_to_activate'),
|
||||
reset: this.I18n.t('js.admin.type_form.reset_to_defaults'),
|
||||
label_group: this.I18n.t('js.label_group'),
|
||||
new_group: this.I18n.t('js.admin.type_form.new_group'),
|
||||
label_inactive: this.I18n.t('js.admin.type_form.inactive'),
|
||||
custom_field: this.I18n.t('js.admin.type_form.custom_field'),
|
||||
add_group: this.I18n.t('js.admin.type_form.add_group'),
|
||||
add_table: this.I18n.t('js.admin.type_form.add_table'),
|
||||
};
|
||||
|
||||
private autoscroll:any;
|
||||
|
||||
private element:HTMLElement;
|
||||
|
||||
private form:HTMLFormElement;
|
||||
|
||||
private submit:HTMLButtonElement;
|
||||
|
||||
public groups:TypeGroup[] = [];
|
||||
|
||||
public inactives:TypeFormAttribute[] = [];
|
||||
|
||||
private attributeDrake:DrakeWithModels;
|
||||
|
||||
private groupsDrake:DrakeWithModels;
|
||||
|
||||
private no_filter_query:string;
|
||||
|
||||
private eventListeners = {
|
||||
typeFormUpdater: () => {
|
||||
this.updateHiddenFields();
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
private elementRef:ElementRef<HTMLElement>,
|
||||
private I18n:I18nService,
|
||||
private dragula:DragulaService,
|
||||
private confirmDialog:ConfirmDialogService,
|
||||
private externalRelationQuery:ExternalRelationQueryConfigurationService,
|
||||
readonly typeBanner:TypeBannerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
ngOnInit():void {
|
||||
// For unclear reasons, this component is initialized twice if used in conjunction with
|
||||
// turbo drive. This then leads to the groups defined in this component being duplicated.
|
||||
// It does not harm to remove them if they exist but it would of course be better if that hack
|
||||
// would not be necessary. The functionality should all be handled in ngOnDestroy.
|
||||
this.dragula.destroy('groups');
|
||||
this.dragula.destroy('attributes');
|
||||
|
||||
// Hook on form submit
|
||||
this.element = this.elementRef.nativeElement;
|
||||
this.no_filter_query = this.element.dataset.noFilterQuery!;
|
||||
this.form = this.element.closest('form')!;
|
||||
this.submit = this.form.querySelector('.form-configuration--save')!;
|
||||
|
||||
// Capture regular form submit
|
||||
this.form.addEventListener('submit', this.eventListeners.typeFormUpdater);
|
||||
|
||||
// Setup groups
|
||||
this.groupsDrake = this
|
||||
.dragula
|
||||
.createGroup('groups', {
|
||||
moves: (el, source, handle:HTMLElement) => handle.classList.contains('group-handle'),
|
||||
})
|
||||
.drake;
|
||||
|
||||
// Setup attributes
|
||||
this.attributeDrake = this
|
||||
.dragula
|
||||
.createGroup('attributes', {
|
||||
moves: (el, source, handle:HTMLElement) => handle.classList.contains('attribute-handle'),
|
||||
})
|
||||
.drake;
|
||||
|
||||
// Get attribute id
|
||||
this.groups = JSON
|
||||
.parse(this.element.dataset.activeGroups!)
|
||||
.filter((group:TypeGroup) => group?.key !== emptyTypeGroup);
|
||||
this.inactives = JSON.parse(this.element.dataset.inactiveAttributes!);
|
||||
|
||||
// Setup autoscroll
|
||||
const that = this;
|
||||
this.autoscroll = new DomAutoscrollService(
|
||||
[
|
||||
document.getElementById('content-body')!,
|
||||
],
|
||||
{
|
||||
margin: 25,
|
||||
maxSpeed: 10,
|
||||
scrollWhenOutside: true,
|
||||
autoScroll(this:any) {
|
||||
const groups = that.groupsDrake && that.groupsDrake.dragging;
|
||||
const attributes = that.attributeDrake && that.attributeDrake.dragging;
|
||||
|
||||
return groups || attributes;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit():void {
|
||||
const menu = this.elementRef.nativeElement.querySelector<HTMLElement>('.toolbar-items')!;
|
||||
installMenuLogic(menu);
|
||||
}
|
||||
|
||||
ngOnDestroy():void {
|
||||
this.dragula.destroy('groups');
|
||||
this.dragula.destroy('attributes');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call
|
||||
this.autoscroll.destroy();
|
||||
}
|
||||
|
||||
deactivateAttribute(attribute:TypeFormAttribute):void {
|
||||
this.updateInactives(this.inactives.concat(attribute));
|
||||
}
|
||||
|
||||
addGroupAndOpenQuery():void {
|
||||
const newGroup = this.createGroup('query');
|
||||
this.editQuery(newGroup);
|
||||
}
|
||||
|
||||
editQuery(group:TypeGroup):void {
|
||||
void this.typeBanner.conditional(
|
||||
'edit_attribute_groups',
|
||||
() => this.typeBanner.showEEOnlyHint(),
|
||||
() => {
|
||||
// Disable display mode and timeline for now since we don't want users to enable it
|
||||
const disabledTabs = {
|
||||
'display-settings': I18n.t('js.work_packages.table_configuration.embedded_tab_disabled'),
|
||||
timelines: I18n.t('js.work_packages.table_configuration.embedded_tab_disabled'),
|
||||
};
|
||||
|
||||
this.externalRelationQuery.show({
|
||||
currentQuery: JSON.parse(group.query),
|
||||
callback: (queryProps:any) => (group.query = JSON.stringify(queryProps)),
|
||||
disabledTabs,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
deleteGroup(group:TypeGroup):void {
|
||||
void this.typeBanner.conditional(
|
||||
'edit_attribute_groups',
|
||||
() => this.typeBanner.showEEOnlyHint(),
|
||||
() => {
|
||||
if (group.type === 'attribute') {
|
||||
this.updateInactives(this.inactives.concat(group.attributes));
|
||||
}
|
||||
|
||||
this.groups = this.groups.filter((el) => el !== group);
|
||||
|
||||
return group;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
createGroup(type:TypeGroupType, groupName = ''):TypeGroup {
|
||||
const group:TypeGroup = {
|
||||
type,
|
||||
name: groupName,
|
||||
key: null,
|
||||
query: this.no_filter_query,
|
||||
attributes: [],
|
||||
};
|
||||
|
||||
this.groups.unshift(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
resetToDefault($event:Event):boolean {
|
||||
this.confirmDialog
|
||||
.confirm({
|
||||
text: {
|
||||
title: this.I18n.t('js.types.attribute_groups.reset_title'),
|
||||
text: this.I18n.t('js.types.attribute_groups.confirm_reset'),
|
||||
button_continue: this.I18n.t('js.label_reset'),
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const input = this.form.querySelector<HTMLInputElement>('input#type_attribute_groups')!;
|
||||
input.value = JSON.stringify([]);
|
||||
|
||||
// Disable our form handler that updates the attribute groups
|
||||
this.form.removeEventListener('submit', this.eventListeners.typeFormUpdater);
|
||||
this.form.requestSubmit();
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
|
||||
$event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
private updateInactives(newValue:TypeFormAttribute[]):void {
|
||||
this.inactives = [...newValue].sort((a, b) => a.translation.localeCompare(b.translation));
|
||||
}
|
||||
|
||||
// We maintain an empty group
|
||||
// that gets hidden in the frontend in case the user
|
||||
// decides to remove all groups
|
||||
// This was necessary since the "default" is actually an empty array of groups
|
||||
private get emptyGroup():TypeGroup {
|
||||
return {
|
||||
type: 'attribute', key: emptyTypeGroup, name: 'empty', attributes: [],
|
||||
};
|
||||
}
|
||||
|
||||
private updateHiddenFields():void {
|
||||
const hiddenField = this.form.querySelector<HTMLInputElement>('.admin-type-form--hidden-field')!;
|
||||
if (this.groups.length === 0) {
|
||||
// Ensure we're adding an empty group if deliberately removing
|
||||
// all values.
|
||||
hiddenField.value = JSON.stringify([this.emptyGroup]);
|
||||
} else {
|
||||
hiddenField.value = JSON.stringify(this.groups);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<div class="toolbar-container -with-dropdown">
|
||||
<div class="toolbar toolbar_empty-title">
|
||||
<ul class="toolbar-items">
|
||||
<li class="toolbar-item">
|
||||
<button type="button -primary"
|
||||
class="form-configuration--reset button"
|
||||
(click)="resetToDefault($event)">
|
||||
<op-icon icon-classes="button--icon icon-undo" />
|
||||
<span class="button--text" [textContent]="text.reset"></span>
|
||||
</button>
|
||||
</li>
|
||||
@if (typeBanner.eeAvailable) {
|
||||
<li
|
||||
class="toolbar-item drop-down">
|
||||
<a class="form-configuration--add-group button -primary" aria-haspopup="true">
|
||||
<op-icon icon-classes="button--icon icon-add" />
|
||||
<span class="button--text" [textContent]="text.label_group"></span>
|
||||
<op-icon icon-classes="button--dropdown-indicator" />
|
||||
</a>
|
||||
<ul class="menu-drop-down-container">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item form-configuration--add-group"
|
||||
[textContent]="text.add_group"
|
||||
(click)="createGroup('attribute', '')"
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item form-configuration--add-group"
|
||||
[textContent]="text.add_table"
|
||||
(click)="addGroupAndOpenQuery()"
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-block wrap">
|
||||
<div class="grid-content small-12 medium-6">
|
||||
<div id="draggable-groups" dragula="groups" [(dragulaModel)]="groups">
|
||||
@for (group of groups; track group) {
|
||||
@if (group.type === 'attribute') {
|
||||
<op-type-form-attribute-group (removeAttribute)="deactivateAttribute($event)"
|
||||
(deleteGroup)="deleteGroup(group)"
|
||||
[group]="group" />
|
||||
}
|
||||
@if (group.type === 'query') {
|
||||
<op-type-form-query-group (editQuery)="editQuery(group)"
|
||||
(deleteGroup)="deleteGroup(group)"
|
||||
[group]="group" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-content small-12 medium-6">
|
||||
<div id="type-form-conf-inactive-group">
|
||||
<div class="group-head">
|
||||
<span class="group-name" [textContent]="text.label_inactive"></span>
|
||||
&ngsp;
|
||||
<span class="advice" [textContent]="text.drag_to_activate"></span>
|
||||
</div>
|
||||
<div class="attributes" dragula="attributes" [(dragulaModel)]="inactives">
|
||||
@for (inactive_attribute of inactives; track inactive_attribute) {
|
||||
<div
|
||||
class="type-form-conf-attribute"
|
||||
[attr.data-key]="inactive_attribute.key">
|
||||
<span class="attribute-handle icon-drag-handle"></span>
|
||||
<span class="attribute-name">
|
||||
{{ inactive_attribute.translation }}
|
||||
@if (inactive_attribute.is_cf) {
|
||||
<span
|
||||
class="attribute-cf-label"
|
||||
[textContent]="text.custom_field">
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- END type form configurator -->
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
<span [textContent]="text.filter_work_packages_by_relation_type"></span>
|
||||
&ngsp;
|
||||
<select class="form--select form--inline-select"
|
||||
data-test-selector="wp-table-configuration-relation-filter"
|
||||
[(ngModel)]="selectedRelationFilter"
|
||||
(ngModelChange)="onRelationFilterSelected()"
|
||||
[compareWith]="compareRelationFilters">
|
||||
|
||||
+25
-19
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnInit, ViewChild,
|
||||
AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit, ViewChild,
|
||||
} from '@angular/core';
|
||||
import { WorkPackageEmbeddedTableComponent } from 'core-app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component';
|
||||
import { WpTableConfigurationService } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.service';
|
||||
import { RestrictedWpTableConfigurationService } from 'core-app/features/work-packages/components/wp-table/external-configuration/restricted-wp-table-configuration.service';
|
||||
import { ExternalQueryConfigurationService } from 'core-app/features/work-packages/components/wp-table/external-configuration/external-query-configuration.service';
|
||||
import { OpQueryConfigurationLocalsToken } from 'core-app/features/work-packages/components/wp-table/external-configuration/external-query-configuration.constants';
|
||||
import { UrlParamsHelperService } from 'core-app/features/work-packages/components/wp-query/url-params-helper';
|
||||
import {
|
||||
@@ -11,11 +12,11 @@ import {
|
||||
} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive';
|
||||
|
||||
export interface QueryConfigurationLocals {
|
||||
service:any;
|
||||
currentQuery:any;
|
||||
service:ExternalQueryConfigurationService;
|
||||
currentQuery:unknown;
|
||||
urlParams?:boolean;
|
||||
disabledTabs?:Record<string, string>;
|
||||
callback:(newQuery:any) => void;
|
||||
callback:(newQuery:unknown) => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -31,18 +32,18 @@ export interface QueryConfigurationLocals {
|
||||
export class ExternalQueryConfigurationComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild('embeddedTableForConfiguration', { static: true }) private embeddedTable:WorkPackageEmbeddedTableComponent;
|
||||
|
||||
queryProps:string|object;
|
||||
readonly locals = inject<QueryConfigurationLocals>(OpQueryConfigurationLocalsToken);
|
||||
readonly urlParamsHelper = inject(UrlParamsHelperService);
|
||||
readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(@Inject(OpQueryConfigurationLocalsToken) readonly locals:QueryConfigurationLocals,
|
||||
readonly urlParamsHelper:UrlParamsHelperService,
|
||||
readonly cdRef:ChangeDetectorRef) {
|
||||
}
|
||||
queryProps:string|object;
|
||||
|
||||
ngOnInit() {
|
||||
if (this.locals.urlParams) {
|
||||
this.queryProps = this.urlParamsHelper.buildV3GetQueryFromJsonParams(this.locals.currentQuery);
|
||||
const currentQuery = typeof this.locals.currentQuery === 'string' ? this.locals.currentQuery : null;
|
||||
this.queryProps = this.urlParamsHelper.buildV3GetQueryFromJsonParams(currentQuery);
|
||||
} else {
|
||||
this.queryProps = this.locals.currentQuery as string;
|
||||
this.queryProps = this.locals.currentQuery as object;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,19 +51,24 @@ export class ExternalQueryConfigurationComponent implements OnInit, AfterViewIni
|
||||
// Open the configuration modal in an asynchronous step
|
||||
// to avoid nesting components in the view initialization.
|
||||
setTimeout(() => {
|
||||
this.embeddedTable.openConfigurationModal(() => {
|
||||
this.service.detach();
|
||||
if (this.locals.urlParams) {
|
||||
this.locals.callback(this.embeddedTable.buildUrlParams());
|
||||
} else {
|
||||
this.locals.callback(this.embeddedTable.buildQueryProps());
|
||||
}
|
||||
void this.embeddedTable.openConfigurationModal(() => {
|
||||
// The modal emits onDataUpdated immediately after tab onSave hooks run.
|
||||
// Defer reading query props by a tick so the embedded query space reflects
|
||||
// the latest filter/column changes before we persist them in form configuration.
|
||||
setTimeout(() => {
|
||||
this.service.detach();
|
||||
if (this.locals.urlParams) {
|
||||
this.locals.callback(this.embeddedTable.buildUrlParams());
|
||||
} else {
|
||||
this.locals.callback(this.embeddedTable.buildQueryProps());
|
||||
}
|
||||
});
|
||||
});
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
public get service():any {
|
||||
public get service():ExternalQueryConfigurationService {
|
||||
return this.locals.service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +1,83 @@
|
||||
.type-form-conf-group,
|
||||
#type-form-conf-inactive-group
|
||||
border-radius: 2px
|
||||
padding: 0px 3px 1px 3px
|
||||
margin-bottom: 20px
|
||||
//-- 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.
|
||||
//++
|
||||
|
||||
.group-head
|
||||
color: var(--fgColor-white)
|
||||
padding: 7px 4px 8px 0px
|
||||
text-transform: uppercase
|
||||
.group-handle
|
||||
cursor: -webkit-grab
|
||||
cursor: grab
|
||||
color: var(--fgColor-white)
|
||||
font-size: 12px
|
||||
op-group-edit-in-place
|
||||
display: inline-block
|
||||
.group-edit-in-place--input
|
||||
color: var(--fgColor-white)
|
||||
.delete-group
|
||||
.Button-visual
|
||||
color: var(--fgColor-white)
|
||||
.attributes
|
||||
min-height: 29px
|
||||
@import ../openproject/mixins
|
||||
|
||||
.type-form-conf-group
|
||||
background: var(--accent-color)
|
||||
.group-name
|
||||
border-color: var(--accent-color)
|
||||
border-width: 1px
|
||||
border-style: solid
|
||||
&:hover
|
||||
cursor: text
|
||||
border-color: var(--borderColor-default)
|
||||
background: var(--control-bgColor-activ)
|
||||
body:has(.type-form-configuration-page)
|
||||
@include extended-content--bottom
|
||||
|
||||
&.-error
|
||||
background: var(--content-form-error-color)
|
||||
.group-name
|
||||
border-color: var(--content-form-error-color)
|
||||
.group-handle
|
||||
color: var(--fgColor-white)
|
||||
#content-body
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.type-form-configuration-page
|
||||
&--wrapper
|
||||
height: 100%
|
||||
overflow: hidden
|
||||
|
||||
#type-form-conf-inactive-group
|
||||
background: var(--fgColor-muted)
|
||||
.visibility-check
|
||||
visibility: hidden
|
||||
.group-head
|
||||
display: block
|
||||
.advice
|
||||
text-transform: initial
|
||||
&--sidebar
|
||||
border-top-left-radius: var(--borderRadius-medium)
|
||||
border-top-right-radius: var(--borderRadius-medium)
|
||||
|
||||
.type-form-conf-attribute
|
||||
padding: 7px 7px 7px 0px
|
||||
margin-bottom: 2px
|
||||
&--sidebar,
|
||||
&--main
|
||||
@include styled-scroll-bar
|
||||
min-height: 0
|
||||
|
||||
background: var(--type-form-conf-attribute--background)
|
||||
&--inactive-list
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
border-top-left-radius: 2px
|
||||
border-bottom-left-radius: 2px
|
||||
border-top-right-radius: 2px
|
||||
border-bottom-right-radius: 2px
|
||||
.attribute-handle
|
||||
cursor: -webkit-grab
|
||||
cursor: grab
|
||||
color: var(--body-font-color)
|
||||
font-size: 12px
|
||||
.delete-attribute:before
|
||||
color: var(--body-font-color)
|
||||
.ActionListItem-visual--leading
|
||||
pointer-events: auto
|
||||
|
||||
.attribute-cf-label
|
||||
font-size: 0.8rem
|
||||
padding-left: 2px
|
||||
color: #4d4d4d
|
||||
&--inactive-item,
|
||||
&--inactive-empty
|
||||
margin-block: var(--base-size-4)
|
||||
|
||||
#type-form-conf-group-template,
|
||||
#type-form-conf-query-template
|
||||
display: none
|
||||
&--inactive-item .ActionListContent
|
||||
padding-inline: var(--base-size-4)
|
||||
|
||||
.group-head,
|
||||
.type-form-conf-attribute
|
||||
display: flex
|
||||
align-items: baseline
|
||||
justify-content: space-between
|
||||
.icon-drag-handle
|
||||
flex-basis: 15px
|
||||
.attribute-name,
|
||||
.group-name
|
||||
flex-basis: 90%
|
||||
@include text-shortener(false)
|
||||
&--row-alignment-spacer
|
||||
min-width: var(--base-size-16)
|
||||
width: var(--base-size-16)
|
||||
min-height: var(--control-small-size)
|
||||
|
||||
&--drag-handle,
|
||||
&--actions
|
||||
align-self: flex-start
|
||||
justify-self: end
|
||||
|
||||
// Query group styles
|
||||
.type-form-query
|
||||
padding: 10px
|
||||
cursor: pointer
|
||||
color: white
|
||||
&--actions-button
|
||||
margin-top: 0
|
||||
|
||||
// Ensure dropdown is shown
|
||||
@media screen and (max-width: $breakpoint-md)
|
||||
&--main-inner
|
||||
padding-left: 0
|
||||
padding-top: var(--base-size-24)
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
--list-item-hover--border-color: transparent;
|
||||
--list-item-hover--color: var(--accent-color);
|
||||
--link-text-decoration: none;
|
||||
--type-form-conf-attribute--background: var(--gray-light);
|
||||
--select-arrow-bg-color-url: url("data:image/svg+xml;utf8,<svg fill='black' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
|
||||
--split-screen-width: 550px;
|
||||
--full-view-split-right-width: 550px;
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* -- copyright
|
||||
* OpenProject is an open source project management software.
|
||||
* Copyright (C) the OpenProject GmbH
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 3.
|
||||
*
|
||||
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
* Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
* Copyright (C) 2010-2013 the ChiliProject Team
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* See COPYRIGHT and LICENSE files for more details.
|
||||
* ++
|
||||
*/
|
||||
|
||||
import GenericDragAndDropController from '../../generic-drag-and-drop.controller';
|
||||
|
||||
export default class extends GenericDragAndDropController {
|
||||
protected buildData(el:Element, target:Element):FormData {
|
||||
const data = super.buildData(el, target);
|
||||
|
||||
if (!data.get('target_id')) {
|
||||
const targetId = this.formConfigurationTargetId(target);
|
||||
if (targetId) {
|
||||
data.append('target_id', targetId);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async drop(el:Element, target:Element, source:Element|null, sibling:Element|null) {
|
||||
if (this.element.querySelector('[data-edit-mode="true"]')) {
|
||||
if (!window.confirm(I18n.t('js.text_are_you_sure_to_cancel'))) {
|
||||
this.cancelDrag();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await super.drop(el, target, source, sibling);
|
||||
}
|
||||
|
||||
private formConfigurationTargetId(target:Element):string|null {
|
||||
return target.closest<HTMLElement>('[data-group-key]')?.dataset.groupKey
|
||||
?? target.getAttribute('data-target-id')
|
||||
?? target.closest<HTMLElement>('[data-target-id]')?.getAttribute('data-target-id')
|
||||
?? null;
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* -- copyright
|
||||
* OpenProject is an open source project management software.
|
||||
* Copyright (C) the OpenProject GmbH
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 3.
|
||||
*
|
||||
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
* Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
* Copyright (C) 2010-2013 the ChiliProject Team
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* See COPYRIGHT and LICENSE files for more details.
|
||||
* ++
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
|
||||
import {
|
||||
ExternalRelationQueryConfigurationService,
|
||||
} from 'core-app/features/work-packages/components/wp-table/external-configuration/external-relation-query-configuration.service';
|
||||
|
||||
export default class TypeFormConfigurationController extends Controller {
|
||||
static targets = ['groupsContainer', 'inactiveContainer'];
|
||||
|
||||
declare readonly groupsContainerTarget:HTMLElement;
|
||||
declare readonly inactiveContainerTarget:HTMLElement;
|
||||
|
||||
static values = {
|
||||
addGroupUrl: String,
|
||||
noFilterQuery: String,
|
||||
groupsUrl: String,
|
||||
};
|
||||
|
||||
declare readonly addGroupUrlValue:string;
|
||||
declare readonly noFilterQueryValue:string;
|
||||
declare readonly groupsUrlValue:string;
|
||||
|
||||
private turboRequests:TurboRequestsService;
|
||||
private externalRelationQueryConfiguration:ExternalRelationQueryConfigurationService;
|
||||
private servicesInitialization?:Promise<void>;
|
||||
|
||||
connect() {
|
||||
this.servicesInitialization ??= this.initializeServices();
|
||||
}
|
||||
|
||||
private async initializeServices() {
|
||||
const context = await window.OpenProject.getPluginContext();
|
||||
this.turboRequests = context.services.turboRequests;
|
||||
this.externalRelationQueryConfiguration = context.services.externalRelationQueryConfiguration;
|
||||
}
|
||||
|
||||
addQueryGroup(event:Event) {
|
||||
event.preventDefault();
|
||||
|
||||
void this.openQueryEditor(this.noFilterQueryValue, (queryProps:unknown) => {
|
||||
void this.postNewGroup('query', queryProps);
|
||||
});
|
||||
}
|
||||
|
||||
inactiveContainerTargetConnected() {
|
||||
const filterListElement = this.element.querySelector<HTMLElement>('[data-controller~="filter--filter-list"]');
|
||||
if (!filterListElement) return;
|
||||
|
||||
const filterListController = this.application.getControllerForElementAndIdentifier(filterListElement, 'filter--filter-list') as { filterLists:() => void }|null;
|
||||
filterListController?.filterLists();
|
||||
}
|
||||
|
||||
editQuery(event:Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const group = (event.currentTarget as HTMLElement).closest<HTMLElement>('[data-group-key]');
|
||||
if (!group) return;
|
||||
|
||||
void this.openQueryEditor(group.dataset.groupQuery ?? this.noFilterQueryValue, (queryProps:unknown) => {
|
||||
const key = group.dataset.groupKey;
|
||||
if (!key) return;
|
||||
|
||||
void this.postQueryUpdate(key, queryProps).then((success) => {
|
||||
if (success) {
|
||||
group.dataset.groupQuery = JSON.stringify(queryProps);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async postNewGroup(groupType:'attribute'|'query', queryProps?:unknown):Promise<void> {
|
||||
await this.servicesInitialization;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
group_type: groupType,
|
||||
});
|
||||
|
||||
if (queryProps) {
|
||||
body.set('query', JSON.stringify(queryProps));
|
||||
}
|
||||
|
||||
await this.turboRequests.request(this.addGroupUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'text/vnd.turbo-stream.html',
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
private async postQueryUpdate(groupKey:string, queryProps:unknown):Promise<boolean> {
|
||||
await this.servicesInitialization;
|
||||
|
||||
await this.turboRequests.request(`${this.groupsUrlValue}/${encodeURIComponent(groupKey)}/update_query`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'text/vnd.turbo-stream.html',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
query: JSON.stringify(queryProps),
|
||||
}),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async openQueryEditor(queryJson:string, callback:(queryProps:unknown) => void) {
|
||||
await this.servicesInitialization;
|
||||
|
||||
const currentQuery = JSON.parse(queryJson) as unknown;
|
||||
const disabledTabs = {
|
||||
'display-settings': I18n.t('js.work_packages.table_configuration.embedded_tab_disabled'),
|
||||
timelines: I18n.t('js.work_packages.table_configuration.embedded_tab_disabled'),
|
||||
};
|
||||
|
||||
if (!this.element.isConnected) return;
|
||||
|
||||
this.externalRelationQueryConfiguration.show({
|
||||
currentQuery,
|
||||
callback,
|
||||
disabledTabs,
|
||||
});
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* -- copyright
|
||||
* OpenProject is an open source project management software.
|
||||
* Copyright (C) the OpenProject GmbH
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 3.
|
||||
*
|
||||
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
* Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
* Copyright (C) 2010-2013 the ChiliProject Team
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* See COPYRIGHT and LICENSE files for more details.
|
||||
* ++
|
||||
*/
|
||||
|
||||
import TypeFormConfigurationDragAndDropController from './drag-and-drop.controller';
|
||||
import type { Drake } from 'dragula';
|
||||
import type { DomAutoscrollService } from 'core-app/shared/helpers/drag-and-drop/dom-autoscroll.service';
|
||||
|
||||
interface ReconnectableDragAndDropController {
|
||||
drake:Drake|null;
|
||||
autoscroll:DomAutoscrollService|null;
|
||||
connect:() => void;
|
||||
}
|
||||
|
||||
export default class TypeFormConfigurationRowsDragAndDropController extends TypeFormConfigurationDragAndDropController {
|
||||
static targets = ['container', 'scrollContainer'];
|
||||
|
||||
async drop(el:Element, target:Element, source:Element|null, sibling:Element|null) {
|
||||
await super.drop(el, target, source, sibling);
|
||||
|
||||
// After drop completes and DOM updates, reinitialize dragula
|
||||
setTimeout(() => {
|
||||
if (!this.element.isConnected) return;
|
||||
|
||||
const parent = this as unknown as ReconnectableDragAndDropController;
|
||||
if (parent.drake) {
|
||||
parent.drake.destroy();
|
||||
parent.drake = null;
|
||||
}
|
||||
if (parent.autoscroll) {
|
||||
parent.autoscroll.destroy();
|
||||
parent.autoscroll = null;
|
||||
}
|
||||
super.connect();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,24 @@ module WorkPackageTypes
|
||||
expect(contract.errors.details[:base]).to include(action: "Edit Attribute Groups", error: :error_enterprise_only)
|
||||
end
|
||||
end
|
||||
|
||||
context "when normalizing an unnamed legacy group" do
|
||||
before do
|
||||
model.update_column(:attribute_groups, [
|
||||
["", ["assignee"]],
|
||||
[:details, ["priority"]]
|
||||
])
|
||||
end
|
||||
|
||||
it "is valid" do
|
||||
model.attribute_groups = [
|
||||
[I18n.t("types.edit.form_configuration.untitled_group"), ["assignee"]],
|
||||
[:details, ["priority"]]
|
||||
]
|
||||
|
||||
expect(contract).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with enterprise features enabled", with_ee: %i[edit_attribute_groups] do
|
||||
@@ -194,6 +212,18 @@ module WorkPackageTypes
|
||||
end
|
||||
end
|
||||
|
||||
context "when a custom group uses the visible name of a default group" do
|
||||
it "is invalid and adds :duplicate_group error for the visible name" do
|
||||
model.attribute_groups = [
|
||||
[:details, ["priority"]],
|
||||
["Details", ["assignee"]]
|
||||
]
|
||||
|
||||
expect(contract).not_to be_valid
|
||||
expect(contract.errors.details[:attribute_groups]).to include(error: :duplicate_group, group: "Details")
|
||||
end
|
||||
end
|
||||
|
||||
context "when an attribute group contains unknown attributes" do
|
||||
let(:invalid_group) { ["foo", ["unknown_attribute"]] }
|
||||
|
||||
@@ -219,6 +249,16 @@ module WorkPackageTypes
|
||||
.to include(hash_including(error: :query_invalid, group: "query_group"))
|
||||
end
|
||||
end
|
||||
|
||||
context "with a persisted embedded query owned by another user" do
|
||||
let(:query) { create(:query, user: create(:user), name: "Existing embedded query") }
|
||||
|
||||
it "is valid" do
|
||||
model.attribute_groups = [["query_group", [query]]]
|
||||
|
||||
expect(contract).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe WorkPackageTypes::FormConfigurationGroupsTabController do
|
||||
let(:type) { create(:type) }
|
||||
let(:user) { create(:admin) }
|
||||
let(:temporary_group_key) { described_class::TEMPORARY_GROUP_KEY }
|
||||
|
||||
before do
|
||||
allow(User).to receive(:current).and_return(user)
|
||||
end
|
||||
|
||||
describe "POST #add_group", with_ee: %i[edit_attribute_groups] do
|
||||
it "renders a temporary attribute group from group_type params" do
|
||||
expect do
|
||||
post :add_group, params: { type_id: type.id, group_type: "attribute" }, format: :turbo_stream
|
||||
end.not_to change { type.reload.attribute_groups.count }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #create", with_ee: %i[edit_attribute_groups] do
|
||||
it "persists a group when saving it" do
|
||||
expect do
|
||||
post :create,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
group: { group_type: "attribute", name: "New Group" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
end.to change { type.reload.attribute_groups.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(type.reload.attribute_groups.first.key).to eq("New Group")
|
||||
end
|
||||
|
||||
it "persists a query group with an empty query when saving it" do
|
||||
query = create(:query, user:)
|
||||
query.add_filter("parent", "=", [Queries::Filters::TemplatedValue::KEY])
|
||||
query.save!
|
||||
query_props = API::V3::Queries::QueryParamsRepresenter.new(query).to_json
|
||||
|
||||
post :create,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
group: { group_type: "query", name: "Empty test", query: query_props }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
query_group = type.reload.attribute_groups.detect { |group| group.is_a?(Type::QueryGroup) }
|
||||
expect(query_group.attributes).to be_a(Query)
|
||||
expect(query_group.key).to eq("Empty test")
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH #update (rename)", with_ee: %i[edit_attribute_groups] do
|
||||
before do
|
||||
type.update_column(:attribute_groups, [
|
||||
["First group", %w[priority]],
|
||||
["Second group", %w[assignee]]
|
||||
])
|
||||
end
|
||||
|
||||
context "when renaming to a duplicate name" do
|
||||
it "returns an error without the attribute prefix" do
|
||||
patch :update,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
key: "First group",
|
||||
group: { name: "Second group" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include("Second group")
|
||||
expect(response.body).to include("Group names must be unique.")
|
||||
expect(response.body).not_to include("Form configuration")
|
||||
end
|
||||
|
||||
it "preserves the entered name in the input field" do
|
||||
patch :update,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
key: "First group",
|
||||
group: { name: "Second group" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response.body).to include("Second group")
|
||||
end
|
||||
end
|
||||
|
||||
context "when renaming to a blank name" do
|
||||
it "returns an error" do
|
||||
patch :update,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
key: "First group",
|
||||
group: { name: "" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #create (duplicate name)", with_ee: %i[edit_attribute_groups] do
|
||||
before do
|
||||
type.update_column(:attribute_groups, [["Existing group", %w[priority]]])
|
||||
end
|
||||
|
||||
it "returns an error when creating a group with a duplicate name" do
|
||||
post :create,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
group: { group_type: "attribute", name: "Existing group" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('action="update"')
|
||||
expect(response.body).to include('target="work-package-types-form-configuration-main-content-component"')
|
||||
expect(response.body).not_to include("Form configuration")
|
||||
end
|
||||
|
||||
it "returns a main content turbo stream response" do
|
||||
post :create,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
group: { group_type: "attribute", name: "Existing group" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('target="work-package-types-form-configuration-main-content-component"')
|
||||
end
|
||||
|
||||
it "does not render the existing group twice when a query group create fails" do
|
||||
query = create(:query, user:)
|
||||
query_props = API::V3::Queries::QueryParamsRepresenter.new(query).to_json
|
||||
|
||||
post :create,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
group: { group_type: "query", name: "Existing group", query: query_props }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body.scan('data-group-key="Existing group"').count).to eq(1)
|
||||
expect(response.body).to include('data-group-key="__new_form_configuration_group__"')
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #create (default group name)", with_ee: %i[edit_attribute_groups] do
|
||||
it "returns an error when creating a group with the visible name of a default group" do
|
||||
post :create,
|
||||
params: {
|
||||
type_id: type.id,
|
||||
group: { group_type: "attribute", name: "Details" }
|
||||
},
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include("Details")
|
||||
expect(response.body).to include("Group names must be unique.")
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT #drop", with_ee: %i[edit_attribute_groups] do
|
||||
it "reorders groups using the requested position" do
|
||||
type.update_column(:attribute_groups, [
|
||||
[:details, %w[priority]],
|
||||
["Custom group", %w[version]],
|
||||
[:people, %w[assignee]]
|
||||
])
|
||||
|
||||
put :drop,
|
||||
params: { type_id: type.id, key: "Custom group", position: 1 },
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(type.reload.attribute_groups.map(&:key)).to eq(["Custom group", :details, :people])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe WorkPackageTypes::FormConfigurationTabController do
|
||||
let(:type) { create(:type) }
|
||||
let(:user) { create(:admin) }
|
||||
|
||||
before do
|
||||
allow(User).to receive(:current).and_return(user)
|
||||
type.update_column(:attribute_groups, [[:details, %w[priority version]]])
|
||||
end
|
||||
|
||||
describe "PUT #drop", with_ee: %i[edit_attribute_groups] do
|
||||
it "uses the row_key param and moves the row to inactive" do
|
||||
put :drop,
|
||||
params: { type_id: type.id, row_key: "priority", target_id: "inactive", position: 1 },
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(type.reload.attribute_groups.flat_map(&:members)).not_to include("priority")
|
||||
end
|
||||
|
||||
it "moves the row into another active section at the requested position" do
|
||||
type.update_column(:attribute_groups, [
|
||||
[:details, %w[priority]],
|
||||
["Custom group", %w[version]]
|
||||
])
|
||||
|
||||
put :drop,
|
||||
params: { type_id: type.id, row_key: "priority", target_id: "Custom group", position: 1 },
|
||||
format: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
target_group = type.reload.attribute_groups.find { |group| group.key == "Custom group" }
|
||||
expect(target_group.members).to eq(%w[priority version])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -133,6 +133,24 @@ RSpec.describe WorkPackageTypes::FormConfigurationTabController do
|
||||
expect(response).to render_template(:edit)
|
||||
end
|
||||
end
|
||||
|
||||
context "with malformed attribute group JSON" do
|
||||
let(:params) do
|
||||
{
|
||||
type_id: type.id,
|
||||
type: {
|
||||
attribute_groups: "{"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it "renders the edit tab instead of raising" do
|
||||
put :update, params: params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to render_template(:edit)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -93,21 +93,21 @@ RSpec.describe "form query configuration", :js do
|
||||
end
|
||||
|
||||
it "can save an empty query group" do
|
||||
form.add_query_group("Empty test", :children)
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
type_bug.reload
|
||||
form.add_query_group("Empty test", :children, expect: false)
|
||||
form.expect_group("Empty test", "Empty test")
|
||||
end
|
||||
|
||||
query_group = type_bug.attribute_groups.detect { |x| x.is_a?(Type::QueryGroup) }
|
||||
expect(query_group.attributes).to be_a(Query)
|
||||
expect(query_group.key).to eq("Empty test")
|
||||
it "can edit a query group by clicking the Work packages table link" do
|
||||
form.add_query_group("Link test", :children, expect: false)
|
||||
form.expect_group("Link test", "Link test")
|
||||
|
||||
form.edit_query_group_via_link("Link test")
|
||||
modal.expect_open
|
||||
modal.cancel
|
||||
end
|
||||
|
||||
it "loads the children from the table split view (Regression #28490)" do
|
||||
form.add_query_group("Subtasks", :children)
|
||||
# Save changed query
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
|
||||
# Visit wp_table
|
||||
wp_table.visit!
|
||||
@@ -131,9 +131,6 @@ RSpec.describe "form query configuration", :js do
|
||||
|
||||
it "does not show a subgroup (Regression #29582)" do
|
||||
form.add_query_group("Subtasks", :children)
|
||||
# Save changed query
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
|
||||
# Visit new wp page
|
||||
visit new_project_work_packages_path(project)
|
||||
@@ -153,11 +150,9 @@ RSpec.describe "form query configuration", :js do
|
||||
filters.expect_filter_count 1
|
||||
filters.add_filter_by("Project", "is (OR)", archived.name)
|
||||
filters.expect_filter_count 2
|
||||
filters.save
|
||||
end
|
||||
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
|
||||
archived.update_attribute(:active, false)
|
||||
|
||||
visit edit_type_form_configuration_path(type_bug)
|
||||
@@ -178,12 +173,9 @@ RSpec.describe "form query configuration", :js do
|
||||
columns.uncheck_all save_changes: false
|
||||
columns.add "ID", save_changes: false
|
||||
columns.add "Subject", save_changes: false
|
||||
columns.apply
|
||||
end
|
||||
|
||||
# Save changed query
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
|
||||
type_bug.reload
|
||||
query = type_bug.attribute_groups.detect { |x| x.key == "Columns Test" }
|
||||
expect(query).to be_present
|
||||
@@ -198,12 +190,9 @@ RSpec.describe "form query configuration", :js do
|
||||
columns.assume_opened
|
||||
columns.uncheck_all save_changes: false
|
||||
columns.add "ID", save_changes: false
|
||||
columns.apply
|
||||
end
|
||||
|
||||
# Save changed query
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
|
||||
type_bug.reload
|
||||
query = type_bug.attribute_groups.detect { |x| x.key == "Columns Test" }
|
||||
expect(query).to be_present
|
||||
@@ -245,11 +234,9 @@ RSpec.describe "form query configuration", :js do
|
||||
# the templated filter should be hidden in the Filters tab
|
||||
filters.expect_filter_count 1
|
||||
filters.add_filter_by("Type", "is (OR)", type_task.name)
|
||||
filters.save
|
||||
end
|
||||
|
||||
form.save_changes
|
||||
expect_and_dismiss_flash(message: "Successful update.")
|
||||
|
||||
# Visit work package with that type
|
||||
wp_page.visit!
|
||||
wp_page.ensure_page_loaded
|
||||
@@ -293,9 +280,6 @@ RSpec.describe "form query configuration", :js do
|
||||
filters.remove_filter "type"
|
||||
filters.save
|
||||
|
||||
# Save changes
|
||||
form.save_changes
|
||||
|
||||
# Visit wp_page again, expect both listed
|
||||
wp_page.visit!
|
||||
wp_page.ensure_page_loaded
|
||||
|
||||
@@ -47,7 +47,7 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
let(:wp_page) { Pages::FullWorkPackage.new(work_package) }
|
||||
let(:form) { Components::Admin::TypeConfigurationForm.new }
|
||||
|
||||
describe "with EE token", with_ee: %i[edit_attribute_groups] do
|
||||
describe "query group actions with EE token", with_ee: %i[edit_attribute_groups] do
|
||||
describe "default configuration" do
|
||||
let(:dialog) { Components::ConfirmationDialog.new }
|
||||
|
||||
@@ -56,31 +56,38 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
visit edit_type_form_configuration_path(type)
|
||||
end
|
||||
|
||||
def persisted_group_order(type)
|
||||
type.reload.attribute_groups.reject { |group| group.key == :__empty }.map(&:translated_key)
|
||||
end
|
||||
|
||||
def persisted_attribute_order(type, group_key)
|
||||
type.reload.attribute_groups.find { |group| group.key.to_s == group_key.to_s }&.attributes
|
||||
end
|
||||
|
||||
it "resets the form properly after changes" do
|
||||
form.rename_group("Details", "Whatever")
|
||||
form.expect_attribute(key: :assignee)
|
||||
|
||||
# Reset and cancel
|
||||
form.reset_button.click
|
||||
dialog.expect_open
|
||||
dialog.cancel
|
||||
dialog = find_test_selector("type-form-configuration-reset-dialog", visible: :all)
|
||||
within(dialog) do
|
||||
click_button I18n.t("js.button_cancel")
|
||||
end
|
||||
|
||||
expect(page).to have_css(".group-edit-handler", text: "WHATEVER")
|
||||
form.expect_group("Whatever", "Whatever")
|
||||
|
||||
# Click the dialog again after some time
|
||||
# Otherwise this may cause issues due to the animation,
|
||||
# which is why sleep is okay.
|
||||
sleep 1
|
||||
# Wait for dialog close animation to finish before opening it again
|
||||
expect(page).to have_no_css("dialog[open]")
|
||||
|
||||
# Reset and confirm
|
||||
form.reset_button.click
|
||||
dialog.expect_open
|
||||
dialog.confirm
|
||||
dialog = find_test_selector("type-form-configuration-reset-dialog", visible: :all)
|
||||
within(dialog) do
|
||||
click_button I18n.t("button_reset")
|
||||
end
|
||||
|
||||
# Wait for page reload
|
||||
sleep 1
|
||||
|
||||
expect(page).to have_no_css(".group-head", text: "WHATEVER")
|
||||
expect(page).to have_no_css("[data-group-key]", text: /\bWhatever\b/)
|
||||
form.expect_group("details", "Details")
|
||||
form.expect_attribute(key: :assignee)
|
||||
end
|
||||
@@ -92,10 +99,6 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
form.remove_group "Costs"
|
||||
form.remove_group "Other"
|
||||
|
||||
# Save configuration
|
||||
form.save_changes
|
||||
expect_flash(message: "Successful update.")
|
||||
|
||||
form.expect_empty
|
||||
|
||||
# Test the actual type backend
|
||||
@@ -158,12 +161,14 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
form.rename_group("People", "Cool Stuff")
|
||||
|
||||
# Start renaming, but cancel
|
||||
find(".group-edit-handler", text: "COOL STUFF").click
|
||||
input = find(".group-edit-in-place--input")
|
||||
group_key = form.send(:find_group, "Cool Stuff")["data-group-key"]
|
||||
form.send(:open_group_menu, "Cool Stuff")
|
||||
page.find_test_selector("type-form-configuration-group-rename-#{group_key}", visible: :all).click
|
||||
input = find_test_selector("type-form-configuration-group-name-input", wait: 10)
|
||||
input.set("FOOBAR")
|
||||
input.send_keys(:escape)
|
||||
expect(page).to have_css(".group-edit-handler", text: "COOL STUFF")
|
||||
expect(page).to have_no_css(".group-edit-handler", text: "FOOBAR")
|
||||
page.find_test_selector("type-form-configuration-group-cancel", wait: 10).click
|
||||
form.expect_group("Cool Stuff", "Cool Stuff")
|
||||
expect(page).to have_no_css("[data-group-key]", text: /\bFOOBAR\b/)
|
||||
|
||||
# Create new group
|
||||
form.add_attribute_group("New Group")
|
||||
@@ -172,10 +177,6 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
# Delete attribute from group
|
||||
form.remove_attribute("assignee")
|
||||
|
||||
# Save configuration
|
||||
form.save_changes
|
||||
expect_flash(message: "Successful update.")
|
||||
|
||||
# Expect configuration to be correct now
|
||||
form.expect_no_attribute("assignee", "Cool Stuff")
|
||||
|
||||
@@ -203,7 +204,9 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
# Test the actual type backend
|
||||
type.reload
|
||||
expect(type.attribute_groups.map(&:key))
|
||||
.to include("Cool Stuff", :estimates_and_progress, "Whatever", "New Group")
|
||||
.to include(:people, :estimates_and_progress, :details, "New Group")
|
||||
expect(type.attribute_groups.detect { |g| g.key == :people }&.display_name).to eq("Cool Stuff")
|
||||
expect(type.attribute_groups.detect { |g| g.key == :details }&.display_name).to eq("Whatever")
|
||||
|
||||
# Visit work package with that type
|
||||
wp_page.visit!
|
||||
@@ -246,6 +249,192 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
expect(wp_page).not_to have_alert_dialog
|
||||
loading_indicator_saveguard
|
||||
end
|
||||
|
||||
it "removes a newly added unsaved custom group when canceling edit" do
|
||||
initial_order = form.group_order
|
||||
|
||||
form.add_button_dropdown.click
|
||||
click_on I18n.t("types.edit.form_configuration.add_attribute_group")
|
||||
|
||||
expect(page.find_test_selector("type-form-configuration-group-name-input", wait: 10).value).to eq("")
|
||||
|
||||
page.find_test_selector("type-form-configuration-group-cancel", wait: 10).click
|
||||
|
||||
expect(form.group_order).to eq(initial_order)
|
||||
end
|
||||
|
||||
it "keeps a saved custom group when canceling rename" do
|
||||
form.add_attribute_group("Saved custom group")
|
||||
|
||||
visit edit_type_form_configuration_path(type)
|
||||
|
||||
group_key = form.send(:find_group, "Saved custom group")["data-group-key"]
|
||||
form.send(:open_group_menu, "Saved custom group")
|
||||
page.find_test_selector("type-form-configuration-group-rename-#{group_key}", visible: :all).click
|
||||
|
||||
input = page.find_test_selector("type-form-configuration-group-name-input", wait: 10)
|
||||
expect(input.value).to eq("Saved custom group")
|
||||
|
||||
input.set("Renamed group")
|
||||
page.find_test_selector("type-form-configuration-group-cancel", wait: 10).click
|
||||
|
||||
form.expect_group("Saved custom group", "Saved custom group")
|
||||
expect(page).to have_no_css("[data-group-key]", text: /\bRenamed group\b/)
|
||||
end
|
||||
|
||||
it "shows only the edit action for query rows" do
|
||||
form.add_query_group("Subtasks", :children)
|
||||
|
||||
menu_id = form.open_query_menu("Subtasks")
|
||||
menu_selector = "##{menu_id}"
|
||||
|
||||
expect(page).to have_selector(menu_selector, text: I18n.t("types.edit.form_configuration.edit_query"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_to_top"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_up"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_down"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_to_bottom"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("button_delete"))
|
||||
end
|
||||
|
||||
it "shows only delete for a single attribute row" do
|
||||
form.add_attribute_group("New Group")
|
||||
form.move_to(:category, "New Group")
|
||||
|
||||
menu_id = form.open_attribute_menu(:category)
|
||||
menu_selector = "##{menu_id}"
|
||||
|
||||
expect(page).to have_selector(menu_selector, text: I18n.t("button_delete"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_to_top"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_up"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_down"))
|
||||
expect(page).to have_no_selector("#{menu_selector} [role='menuitem']", text: I18n.t("label_agenda_item_move_to_bottom"))
|
||||
end
|
||||
|
||||
it "shows move actions only where valid for multi-row groups" do
|
||||
details_order = form.attribute_order("Details")
|
||||
|
||||
first_row_menu_id = form.open_attribute_menu(details_order.first)
|
||||
|
||||
within "##{first_row_menu_id}" do
|
||||
expect(page).to have_text(I18n.t("label_agenda_item_move_down"))
|
||||
expect(page).to have_text(I18n.t("label_agenda_item_move_to_bottom"))
|
||||
expect(page).to have_no_text(I18n.t("label_agenda_item_move_to_top"))
|
||||
expect(page).to have_no_text(I18n.t("label_agenda_item_move_up"))
|
||||
expect(page).to have_text(I18n.t("button_delete"))
|
||||
end
|
||||
|
||||
find("body").click
|
||||
|
||||
last_row_menu_id = form.open_attribute_menu(details_order.last)
|
||||
|
||||
within "##{last_row_menu_id}" do
|
||||
expect(page).to have_text(I18n.t("label_agenda_item_move_to_top"))
|
||||
expect(page).to have_text(I18n.t("label_agenda_item_move_up"))
|
||||
expect(page).to have_no_text(I18n.t("label_agenda_item_move_down"))
|
||||
expect(page).to have_no_text(I18n.t("label_agenda_item_move_to_bottom"))
|
||||
expect(page).to have_text(I18n.t("button_delete"))
|
||||
end
|
||||
end
|
||||
|
||||
it "opens the query editor from the query row action" do
|
||||
form.add_query_group("Subtasks", :children)
|
||||
|
||||
menu_id = form.open_query_menu("Subtasks")
|
||||
|
||||
within "##{menu_id}" do
|
||||
click_button I18n.t("types.edit.form_configuration.edit_query")
|
||||
end
|
||||
|
||||
expect(page).to have_css(".wp-table--configuration-modal")
|
||||
end
|
||||
|
||||
it "reorders and deletes groups via group actions" do
|
||||
expected_order = persisted_group_order(type)
|
||||
moving_group = expected_order.second
|
||||
initial_updated_at = type.updated_at
|
||||
|
||||
form.invoke_group_action(moving_group, I18n.t("label_agenda_item_move_up"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
index = expected_order.index(moving_group)
|
||||
expected_order[index], expected_order[index - 1] = expected_order[index - 1], expected_order[index]
|
||||
expect(persisted_group_order(type)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_group_action(moving_group, I18n.t("label_agenda_item_move_to_bottom"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
expected_order.delete(moving_group)
|
||||
expected_order << moving_group
|
||||
expect(persisted_group_order(type)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_group_action(moving_group, I18n.t("label_agenda_item_move_up"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
index = expected_order.index(moving_group)
|
||||
expected_order[index], expected_order[index - 1] = expected_order[index - 1], expected_order[index]
|
||||
expect(persisted_group_order(type)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_group_action(moving_group, I18n.t("label_agenda_item_move_to_top"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
expected_order.delete(moving_group)
|
||||
expected_order.unshift(moving_group)
|
||||
expect(persisted_group_order(type)).to eq(expected_order)
|
||||
|
||||
deleted_group = expected_order.last
|
||||
initial_updated_at = type.updated_at
|
||||
accept_confirm I18n.t("types.edit.form_configuration.confirm_delete_group") do
|
||||
form.invoke_group_action(deleted_group, I18n.t("button_delete"))
|
||||
end
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
expected_order.delete(deleted_group)
|
||||
expect(persisted_group_order(type)).to eq(expected_order)
|
||||
end
|
||||
|
||||
it "reorders and deletes attribute rows via row actions" do
|
||||
expected_order = persisted_attribute_order(type, :details)
|
||||
moving_attribute = expected_order.second
|
||||
initial_updated_at = type.updated_at
|
||||
|
||||
form.invoke_attribute_action(moving_attribute, I18n.t("label_agenda_item_move_up"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
index = expected_order.index(moving_attribute)
|
||||
expected_order[index], expected_order[index - 1] = expected_order[index - 1], expected_order[index]
|
||||
expect(persisted_attribute_order(type, :details)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_attribute_action(moving_attribute, I18n.t("label_agenda_item_move_down"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
index = expected_order.index(moving_attribute)
|
||||
expected_order[index], expected_order[index + 1] = expected_order[index + 1], expected_order[index]
|
||||
expect(persisted_attribute_order(type, :details)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_attribute_action(moving_attribute, I18n.t("label_agenda_item_move_to_bottom"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
expected_order.delete(moving_attribute)
|
||||
expected_order << moving_attribute
|
||||
expect(persisted_attribute_order(type, :details)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_attribute_action(moving_attribute, I18n.t("label_agenda_item_move_up"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
index = expected_order.index(moving_attribute)
|
||||
expected_order[index], expected_order[index - 1] = expected_order[index - 1], expected_order[index]
|
||||
expect(persisted_attribute_order(type, :details)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_attribute_action(moving_attribute, I18n.t("label_agenda_item_move_to_top"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
expected_order.delete(moving_attribute)
|
||||
expected_order.unshift(moving_attribute)
|
||||
expect(persisted_attribute_order(type, :details)).to eq(expected_order)
|
||||
|
||||
initial_updated_at = type.updated_at
|
||||
form.invoke_attribute_action(moving_attribute, I18n.t("button_delete"))
|
||||
wait_for { type.reload.updated_at }.not_to eq(initial_updated_at)
|
||||
expected_order.delete(moving_attribute)
|
||||
expect(persisted_attribute_order(type, :details)).to eq(expected_order)
|
||||
end
|
||||
end
|
||||
|
||||
describe "required custom field" do
|
||||
@@ -265,14 +454,12 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
it "shows the field" do
|
||||
# Should be initially disabled
|
||||
form.expect_inactive(cf_identifier)
|
||||
form.expect_attribute(key: cf_identifier, translation: "MyNumber")
|
||||
|
||||
# Add into new group
|
||||
form.add_attribute_group("New Group")
|
||||
form.move_to(cf_identifier, "New Group")
|
||||
form.expect_attribute(key: cf_identifier)
|
||||
|
||||
form.save_changes
|
||||
expect_flash(message: "Successful update.")
|
||||
form.expect_attribute(key: cf_identifier, translation: "MyNumber")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -300,9 +487,6 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
|
||||
# Make visible
|
||||
form.expect_attribute(key: cf_identifier)
|
||||
|
||||
form.save_changes
|
||||
expect_flash(message: "Successful update.")
|
||||
end
|
||||
|
||||
context "if inactive in project" do
|
||||
@@ -322,7 +506,7 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
expect(page).to have_css(".custom-field-#{custom_field.id} td", text: "MyNumber")
|
||||
expect(page).to have_css(".custom-field-#{custom_field.id} td", text: type.name)
|
||||
|
||||
id_checkbox = find("#project_work_package_custom_field_ids_#{custom_field.id}")
|
||||
id_checkbox = find("input[name='project[work_package_custom_field_ids][]'][value='#{custom_field.id}']")
|
||||
expect(id_checkbox).not_to be_checked
|
||||
id_checkbox.set(true)
|
||||
|
||||
@@ -362,32 +546,58 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
project_cf_settings_page.visit!
|
||||
expect(page).to have_css(".custom-field-#{custom_field.id} td", text: "MyNumber")
|
||||
expect(page).to have_css(".custom-field-#{custom_field.id} td", text: type.name)
|
||||
expect(page).to have_css("#project_work_package_custom_field_ids_#{custom_field.id}[checked]")
|
||||
expect(page).to have_css("input[name='project[work_package_custom_field_ids][]'][value='#{custom_field.id}'][checked]")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "without EE token", with_ee: false do
|
||||
let(:dialog) { Components::ConfirmationDialog.new }
|
||||
|
||||
it "must disable adding and renaming groups" do
|
||||
it "hides protected group actions" do
|
||||
login_as(admin)
|
||||
visit edit_type_form_configuration_path(type)
|
||||
|
||||
find(".group-edit-handler", text: "DETAILS").click
|
||||
dialog.expect_open
|
||||
expect(page).to have_no_test_selector("type-form-configuration-add-button")
|
||||
|
||||
menu_id = form.send(:open_group_menu, "Details")
|
||||
within "##{menu_id}" do
|
||||
expect(page).to have_no_text(I18n.t("types.edit.form_configuration.rename_group"))
|
||||
expect(page).to have_no_text(I18n.t("button_delete"))
|
||||
end
|
||||
end
|
||||
|
||||
it "hides protected query group actions" do
|
||||
query = build(:global_query, user_id: 0)
|
||||
type.attribute_groups = [["Subtasks", [query]]]
|
||||
type.save!
|
||||
|
||||
login_as(admin)
|
||||
visit edit_type_form_configuration_path(type)
|
||||
|
||||
expect(page).to have_no_test_selector("type-form-configuration-query-actions-Subtasks")
|
||||
end
|
||||
end
|
||||
|
||||
describe "with EE token", with_ee: %i[edit_attribute_groups] do
|
||||
it "shows protected group actions" do
|
||||
login_as(admin)
|
||||
visit edit_type_form_configuration_path(type)
|
||||
|
||||
menu_id = form.send(:open_group_menu, "Details")
|
||||
within "##{menu_id}" do
|
||||
expect(page).to have_text(I18n.t("types.edit.form_configuration.rename_group"))
|
||||
expect(page).to have_text(I18n.t("button_delete"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "form submission", :js, with_ee: %i[edit_attribute_groups] do
|
||||
# Regression test for double form submission happening on the form configuration page
|
||||
it "only submits the form once (Regression#70297)" do
|
||||
it "only creates one group per add action" do
|
||||
call_count = 0
|
||||
subscription =
|
||||
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
|
||||
payload => { controller:, action: }
|
||||
if controller == "WorkPackageTypes::FormConfigurationTabController" && action == "update"
|
||||
if controller == "WorkPackageTypes::FormConfigurationGroupsTabController" && action == "create"
|
||||
call_count += 1
|
||||
end
|
||||
end
|
||||
@@ -395,24 +605,11 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
login_as(admin)
|
||||
visit edit_type_form_configuration_path(type)
|
||||
|
||||
# Wait for form to load
|
||||
form.expect_group("details", "Details")
|
||||
|
||||
# Simulate native mousedown -> mouseup -> click sequence
|
||||
# Including some sleep time to simulate the user actually
|
||||
# holding the mouse button for > 50ms then releasing it.
|
||||
save_button = find(".form-configuration--save")
|
||||
|
||||
save_button.execute_script("this.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))")
|
||||
sleep 0.1
|
||||
save_button.execute_script("this.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))")
|
||||
sleep 0.1
|
||||
save_button.execute_script("this.dispatchEvent(new MouseEvent('click', { bubbles: true }))")
|
||||
|
||||
expect_flash(message: "Successful update.")
|
||||
form.add_attribute_group("New Group")
|
||||
|
||||
ActiveSupport::Notifications.unsubscribe(subscription)
|
||||
expect(call_count).to eq(1), "Expected 1 form submission but got #{call_count}"
|
||||
expect(call_count).to eq(1), "Expected 1 group creation but got #{call_count}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,7 +38,6 @@ RSpec.describe "Reset form configuration",
|
||||
|
||||
let(:project) { create(:project, types: [type]) }
|
||||
let(:form) { Components::Admin::TypeConfigurationForm.new }
|
||||
let(:dialog) { Components::ConfirmationDialog.new }
|
||||
|
||||
describe "with EE token and CFs", with_ee: %i[edit_attribute_groups] do
|
||||
let(:custom_fields) { [custom_field] }
|
||||
@@ -63,20 +62,34 @@ RSpec.describe "Reset form configuration",
|
||||
form.move_to(cf_identifier, "New Group")
|
||||
form.expect_attribute(key: cf_identifier)
|
||||
|
||||
form.save_changes
|
||||
expect_flash(message: "Successful update.")
|
||||
|
||||
SeleniumHubWaiter.wait
|
||||
form.reset_button.click
|
||||
dialog.expect_open
|
||||
dialog.confirm
|
||||
expect(page).to have_test_selector("type-form-configuration-reset-dialog")
|
||||
page.within_test_selector "type-form-configuration-reset-dialog" do
|
||||
click_button I18n.t("button_reset")
|
||||
end
|
||||
|
||||
# Wait for page reload
|
||||
SeleniumHubWaiter.wait
|
||||
wait_for do
|
||||
type.reload.attribute_groups.reject { |group| group.key == :__empty }.map(&:translated_key)
|
||||
end.to eq([
|
||||
"People",
|
||||
"Estimates and progress",
|
||||
"Details",
|
||||
"Other",
|
||||
"Costs"
|
||||
])
|
||||
|
||||
expect(page).to have_no_css(".group-head", text: "NEW GROUP")
|
||||
expect(page).to have_css(".group-head", text: "OTHER")
|
||||
type.reload
|
||||
visit edit_type_form_configuration_path(type)
|
||||
|
||||
expect(form.group_order).to eq(
|
||||
[
|
||||
"People",
|
||||
"Estimates and progress",
|
||||
"Details",
|
||||
"Other",
|
||||
"Costs"
|
||||
]
|
||||
)
|
||||
|
||||
expect(type.custom_field_ids).to be_empty
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ RSpec.describe TypesHelper do
|
||||
|
||||
it "has a proper structure" do
|
||||
# The group's name/key
|
||||
expect(subject.first[:key]).to eq "group one"
|
||||
expect(subject.first[:name]).to eq "group one"
|
||||
|
||||
# The groups attributes
|
||||
@@ -78,6 +79,14 @@ RSpec.describe TypesHelper do
|
||||
expect(subject.first[:attributes].first[:key]).to eq "date"
|
||||
expect(subject.first[:attributes].first[:translation]).to eq "Date"
|
||||
end
|
||||
|
||||
it "includes the key for built-in groups" do
|
||||
allow(type)
|
||||
.to receive(:attribute_groups)
|
||||
.and_return [Type::AttributeGroup.new(type, :details, ["date"])]
|
||||
|
||||
expect(subject.first[:key]).to eq :details
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,7 +40,10 @@ RSpec.describe WorkPackageTypes::AttributeGroups::Transformer do
|
||||
let(:raw_groups) { [] }
|
||||
|
||||
it "returns an empty array" do
|
||||
expect(transformer.call).to eq([])
|
||||
result = transformer.call
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,7 +63,10 @@ RSpec.describe WorkPackageTypes::AttributeGroups::Transformer do
|
||||
end
|
||||
|
||||
it "returns transformed group with symbolized key and attribute keys" do
|
||||
expect(transformer.call).to eq([[:custom, ["custom_field_1", "custom_field_2"]]])
|
||||
result = transformer.call
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.result).to eq([[:custom, ["custom_field_1", "custom_field_2"], "Custom"]])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,7 +82,10 @@ RSpec.describe WorkPackageTypes::AttributeGroups::Transformer do
|
||||
end
|
||||
|
||||
it "uses name as group name" do
|
||||
expect(transformer.call).to eq([["General Info", ["subject"]]])
|
||||
result = transformer.call
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.result).to eq([["General Info", ["subject"]]])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -99,8 +108,9 @@ RSpec.describe WorkPackageTypes::AttributeGroups::Transformer do
|
||||
|
||||
it "returns a group with a Query instance" do
|
||||
result = transformer.call
|
||||
name, entries = result.first
|
||||
name, entries = result.result.first
|
||||
|
||||
expect(result).to be_success
|
||||
expect(name).to eq("Embedded Table")
|
||||
expect(entries.first).to be_a(Query)
|
||||
expect(entries.first.name).to eq("Embedded table: Embedded Table")
|
||||
@@ -118,8 +128,12 @@ RSpec.describe WorkPackageTypes::AttributeGroups::Transformer do
|
||||
]
|
||||
end
|
||||
|
||||
it "raises JSON::ParserError" do
|
||||
expect { transformer.call }.to raise_error(JSON::ParserError)
|
||||
it "returns a failure result" do
|
||||
result = transformer.call
|
||||
|
||||
expect(result).to be_failure
|
||||
expect(result.errors.full_messages.to_sentence)
|
||||
.to eq(I18n.t("types.edit.form_configuration.invalid_query"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
module WorkPackageTypes
|
||||
module FormConfigurationGroups
|
||||
RSpec.describe CreateService, type: :service, with_ee: %i[edit_attribute_groups] do
|
||||
let(:user) { create(:admin) }
|
||||
let(:type) { create(:type) }
|
||||
let(:relation_query_props) do
|
||||
query = create(:query, user:)
|
||||
query.add_filter("parent", "=", [Queries::Filters::TemplatedValue::KEY])
|
||||
query.save!
|
||||
|
||||
::API::V3::Queries::QueryParamsRepresenter
|
||||
.new(query)
|
||||
.to_json
|
||||
end
|
||||
|
||||
subject(:service) { described_class.new(user:, type:) }
|
||||
|
||||
it "creates an attribute group from service params" do
|
||||
result = service.call(group_type: "attribute", name: "New Group")
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.result).to be_a(Type::AttributeGroup)
|
||||
expect(result.result.key).to eq("New Group")
|
||||
expect(type.reload.attribute_groups.first.key).to eq(result.result.key)
|
||||
end
|
||||
|
||||
it "creates a query group from service params" do
|
||||
result = service.call(group_type: "query", name: "Related work", query_props: relation_query_props)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.result).to be_a(Type::QueryGroup)
|
||||
expect(result.result.key).to eq("Related work")
|
||||
expect(result.result.attributes).to be_a(Query)
|
||||
expect(type.reload.attribute_groups.first.key).to eq(result.result.key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
module WorkPackageTypes
|
||||
module FormConfigurationRows
|
||||
RSpec.describe UpdateService, type: :service, with_ee: %i[edit_attribute_groups] do
|
||||
let(:user) { create(:admin) }
|
||||
let(:type) { create(:type, name: "Legacy type") }
|
||||
|
||||
subject(:service) { described_class.new(user:, type:, row_key: "priority") }
|
||||
|
||||
before do
|
||||
type.update_column(:attribute_groups, [
|
||||
["", ["assignee"]],
|
||||
[:details, ["priority"]]
|
||||
])
|
||||
end
|
||||
|
||||
it "normalizes unnamed legacy groups while updating rows" do
|
||||
result = service.call(target_id: "inactive", position: 1)
|
||||
|
||||
expect(result).to be_success
|
||||
|
||||
normalized_group = type.reload.attribute_groups.find do |group|
|
||||
group.translated_key == I18n.t("types.edit.form_configuration.untitled_group")
|
||||
end
|
||||
|
||||
expect(normalized_group).to be_present
|
||||
expect(normalized_group.key).to eq(I18n.t("types.edit.form_configuration.untitled_group"))
|
||||
end
|
||||
|
||||
it "finds legacy symbol attribute keys when moving rows" do
|
||||
type.update_column(:attribute_groups, [
|
||||
[:details, [:version]]
|
||||
])
|
||||
|
||||
result = described_class.new(user:, type:, row_key: "version").call(target_id: "inactive", position: 1)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(type.reload.attribute_groups.flat_map(&:members)).not_to include("version")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -176,6 +176,32 @@ module WorkPackageTypes
|
||||
expect(query.filters.length).to eq(1)
|
||||
expect(query.filters[0].name).to eq(:status_id)
|
||||
end
|
||||
|
||||
it "returns a failure result for invalid query JSON" do
|
||||
invalid_params = {
|
||||
attribute_groups: [
|
||||
{ "type" => "query", "name" => "group1", "query" => "not a json" }
|
||||
]
|
||||
}
|
||||
|
||||
result = service.call(invalid_params)
|
||||
|
||||
expect(result).to be_failure
|
||||
expect(result.errors.full_messages.to_sentence)
|
||||
.to eq(I18n.t("types.edit.form_configuration.invalid_query"))
|
||||
end
|
||||
end
|
||||
|
||||
context "when attribute_groups is malformed JSON" do
|
||||
let(:contract_class) { UpdateFormConfigurationContract }
|
||||
|
||||
it "returns a failure result" do
|
||||
result = service.call(attribute_groups: "{")
|
||||
|
||||
expect(result).to be_failure
|
||||
expect(result.errors[:attribute_groups].to_sentence)
|
||||
.to eq(I18n.t("types.edit.form_configuration.invalid_attribute_groups"))
|
||||
end
|
||||
end
|
||||
|
||||
context "when adding the type to a project" do
|
||||
|
||||
@@ -34,197 +34,207 @@ module Components
|
||||
include Capybara::DSL
|
||||
include Capybara::RSpecMatchers
|
||||
include RSpec::Matchers
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
def add_button_dropdown
|
||||
page.find ".form-configuration--add-group", text: "Group"
|
||||
end
|
||||
|
||||
def add_attribute_group_button
|
||||
page.find "button", text: I18n.t("js.admin.type_form.add_group")
|
||||
end
|
||||
|
||||
def add_table_button
|
||||
page.find "button", text: I18n.t("js.admin.type_form.add_table")
|
||||
page.find(:test_id, "type-form-configuration-add-button", text: /\A#{Regexp.escape(I18n.t(:button_add))}\z/)
|
||||
end
|
||||
|
||||
def reset_button
|
||||
page.find ".form-configuration--reset"
|
||||
page.find_test_selector("type-form-configuration-reset-button")
|
||||
end
|
||||
|
||||
def inactive_group
|
||||
page.find_by_id "type-form-conf-inactive-group"
|
||||
page.find_test_selector("type-form-configuration-inactive-container")
|
||||
end
|
||||
|
||||
def inactive_drop
|
||||
page.find "#type-form-conf-inactive-group .attributes"
|
||||
inactive_group.find("[data-test-selector='type-form-configuration-inactive-list']")
|
||||
end
|
||||
|
||||
def expect_empty
|
||||
expect(page).to have_no_css("#draggable-groups .group-head")
|
||||
expect(page).to have_no_css("[data-group-key]")
|
||||
end
|
||||
|
||||
def find_group(name)
|
||||
head = page.find(".group-head", text: name.upcase)
|
||||
|
||||
# Return the parent of the group-head
|
||||
head.find(:xpath, "..")
|
||||
end
|
||||
|
||||
def checkbox_selector(attribute)
|
||||
".type-form-conf-attribute[data-key='#{attribute}'] .attribute-visibility input"
|
||||
page.find(:xpath, group_xpath(name))
|
||||
end
|
||||
|
||||
def attribute_selector(attribute)
|
||||
".type-form-conf-attribute[data-key='#{attribute}']"
|
||||
%[li[data-attr-key="#{attribute}"]]
|
||||
end
|
||||
|
||||
def find_group_handle(label)
|
||||
find_group(label).find(".group-handle")
|
||||
group_key = find_group(label)["data-group-key"]
|
||||
page.find_test_selector("type-form-configuration-group-handle-#{group_key}", visible: :all)
|
||||
end
|
||||
|
||||
def find_attribute_handle(attribute)
|
||||
page.find("#{attribute_selector(attribute)} .attribute-handle")
|
||||
page.find_test_selector("type-form-configuration-attribute-handle-#{attribute}", visible: :all)
|
||||
end
|
||||
|
||||
def expect_attribute(key:, translation: nil)
|
||||
attribute = page.find(attribute_selector(key))
|
||||
|
||||
unless translation.nil?
|
||||
expect(attribute).to have_css(".attribute-name", text: translation)
|
||||
end
|
||||
expect(attribute).to have_text(translation) if translation
|
||||
end
|
||||
|
||||
def move_to(attribute, group_label)
|
||||
handle = find_attribute_handle(attribute)
|
||||
group = find_group(group_label)
|
||||
drag_and_drop(handle, group)
|
||||
drag_and_drop(find_attribute_handle(attribute), find_group(group_label))
|
||||
expect_group(group_label, group_label, key: attribute)
|
||||
end
|
||||
|
||||
def remove_attribute(attribute)
|
||||
attribute = page.find(attribute_selector(attribute))
|
||||
attribute.find(".delete-attribute").click
|
||||
row = page.find(attribute_selector(attribute))
|
||||
|
||||
within row do
|
||||
page.find_test_selector("type-form-configuration-attribute-actions-#{attribute}").click
|
||||
end
|
||||
|
||||
page.find_test_selector("type-form-configuration-delete-attribute-#{attribute}", visible: :all).click
|
||||
|
||||
page.within_test_selector("type-form-configuration-groups-container") do
|
||||
expect(page).to have_no_css(attribute_selector(attribute))
|
||||
end
|
||||
end
|
||||
|
||||
def drag_and_drop(handle, group)
|
||||
target = group.find(".attributes")
|
||||
def drag_and_drop(handle, target)
|
||||
target_container = drop_container_for(target)
|
||||
source_row = handle.find(:xpath, "./ancestor::li[1]")
|
||||
|
||||
scroll_to_element(group)
|
||||
page
|
||||
.driver
|
||||
.browser
|
||||
.action
|
||||
.move_to(handle.native)
|
||||
.click_and_hold(handle.native)
|
||||
.perform
|
||||
scroll_to_element(target_container)
|
||||
source_row.hover
|
||||
|
||||
scroll_to_element(group)
|
||||
page
|
||||
.driver
|
||||
.browser
|
||||
.action
|
||||
.move_to(target.native)
|
||||
.release
|
||||
.perform
|
||||
page.driver.browser.action
|
||||
.move_to(handle.native)
|
||||
.click_and_hold(handle.native)
|
||||
.perform
|
||||
|
||||
scroll_to_element(target_container)
|
||||
|
||||
target_container.all(":scope > li", visible: true).each do |item|
|
||||
page.driver.browser.action
|
||||
.move_to(item.native)
|
||||
.perform
|
||||
end
|
||||
|
||||
page.driver.browser.action
|
||||
.move_to(target_container.native)
|
||||
.release
|
||||
.perform
|
||||
end
|
||||
|
||||
def add_query_group(name, relation_filter, expect: true)
|
||||
SeleniumHubWaiter.wait unless using_cuprite?
|
||||
|
||||
add_button_dropdown.click
|
||||
add_table_button.click
|
||||
click_on I18n.t("types.edit.form_configuration.add_query_group")
|
||||
|
||||
modal = ::Components::WorkPackages::TableConfigurationModal.new
|
||||
expect(page).to have_css(".wp-table--configuration-modal", wait: 10)
|
||||
|
||||
within ".relation-filter-selector" do
|
||||
select I18n.t("js.relation_labels.#{relation_filter}")
|
||||
unless relation_filter.to_sym == :children
|
||||
within ".relation-filter-selector" do
|
||||
option_label = displayed_relation_filter_label(relation_filter)
|
||||
expect(page).to have_css(".relation-filter-selector option", text: option_label, wait: 10)
|
||||
|
||||
# While we are here, let's check that all relation filters are present.
|
||||
option_labels = %w[
|
||||
children
|
||||
precedes
|
||||
follows
|
||||
relates
|
||||
duplicates
|
||||
duplicated
|
||||
blocks
|
||||
blocked
|
||||
partof
|
||||
includes
|
||||
requires
|
||||
required
|
||||
].map { |filter_name| I18n.t("js.relation_labels.#{filter_name}") }
|
||||
relation_select = page.find_test_selector("wp-table-configuration-relation-filter", visible: :all)
|
||||
relation_select.find(:option, option_label, wait: 10).select_option
|
||||
|
||||
option_labels.each do |label|
|
||||
expect(page).to have_text(label)
|
||||
option_labels = %w[
|
||||
children
|
||||
precedes
|
||||
follows
|
||||
relates
|
||||
duplicates
|
||||
duplicated
|
||||
blocks
|
||||
blocked
|
||||
partof
|
||||
includes
|
||||
requires
|
||||
required
|
||||
].map { |filter_name| I18n.t("js.relation_labels.#{filter_name}") }
|
||||
|
||||
option_labels.each do |label|
|
||||
expect(page).to have_text(label)
|
||||
end
|
||||
end
|
||||
end
|
||||
yield modal if block_given?
|
||||
modal.save
|
||||
|
||||
input = find(".group-edit-in-place--input")
|
||||
input.set(name)
|
||||
yield modal if block_given?
|
||||
modal.save if modal.open?
|
||||
|
||||
fill_group_name(name)
|
||||
save_group
|
||||
|
||||
expect_group(name, name) if expect
|
||||
end
|
||||
|
||||
def edit_query_group(name)
|
||||
if using_cuprite?
|
||||
wait_for_reload
|
||||
else
|
||||
SeleniumHubWaiter.wait
|
||||
end
|
||||
wait_for_turbo
|
||||
|
||||
group_key = find_group(name)["data-group-key"]
|
||||
menu_id = open_query_menu(name)
|
||||
page.find("##{menu_id}", visible: :all)
|
||||
.find(:test_id, "type-form-configuration-edit-query-#{group_key}", visible: :all)
|
||||
.click
|
||||
expect(page).to have_css(".wp-table--configuration-modal")
|
||||
end
|
||||
|
||||
def edit_query_group_via_link(name)
|
||||
wait_for_turbo
|
||||
|
||||
group = find_group(name)
|
||||
group.find(".type-form-query-group--edit-button").click
|
||||
# Wait for the modal to appear.
|
||||
label = I18n.t("types.edit.form_configuration.query_group_label")
|
||||
group.click_button(label)
|
||||
expect(page).to have_css(".wp-table--configuration-modal")
|
||||
end
|
||||
|
||||
def add_attribute_group(name, expect: true)
|
||||
add_button_dropdown.click
|
||||
add_attribute_group_button.click
|
||||
click_on I18n.t("types.edit.form_configuration.add_attribute_group")
|
||||
|
||||
input = find(".group-edit-in-place--input")
|
||||
input.set(name)
|
||||
input.send_keys(:return)
|
||||
fill_group_name(name)
|
||||
save_group
|
||||
|
||||
expect_group(name, name) if expect
|
||||
end
|
||||
|
||||
def save_changes
|
||||
# Save configuration
|
||||
# click_button doesn't seem to work when the button is out of view!?
|
||||
scroll_to_and_click find(".form-configuration--save")
|
||||
wait_for_turbo
|
||||
end
|
||||
|
||||
def rename_group(from, to)
|
||||
find(".group-edit-handler", text: from.upcase).click
|
||||
group_key = find_group(from)["data-group-key"]
|
||||
open_group_menu(from)
|
||||
page.find_test_selector("type-form-configuration-group-rename-#{group_key}", visible: :all).click
|
||||
|
||||
input = find(".group-edit-in-place--input")
|
||||
input.click
|
||||
input.set(to)
|
||||
input.send_keys(:return)
|
||||
fill_group_name(to)
|
||||
save_group
|
||||
|
||||
expect(page).to have_css(".group-edit-handler", text: to.upcase)
|
||||
expect_group(to, to)
|
||||
end
|
||||
|
||||
def remove_group(name)
|
||||
container = find(".group-head", text: name.upcase)
|
||||
accept_confirm I18n.t("types.edit.form_configuration.confirm_delete_group") do
|
||||
menu_id = open_group_menu(name)
|
||||
within "##{menu_id}" do
|
||||
click_link I18n.t("button_delete")
|
||||
end
|
||||
end
|
||||
|
||||
container.find(".delete-group").click
|
||||
|
||||
expect(page).to have_no_css(".group-head", text: name.upcase)
|
||||
expect(page).to have_no_css("[data-group-key]", text: /\b#{Regexp.escape(name)}\b/)
|
||||
end
|
||||
|
||||
def expect_no_attribute(attribute, group)
|
||||
expect(find_group(group)).to have_no_selector(attribute_selector(attribute).to_s)
|
||||
expect(find_group(group)).to have_no_css(attribute_selector(attribute))
|
||||
end
|
||||
|
||||
def expect_group(_label, translation, *attributes)
|
||||
expect(find_group(translation)).to have_css(".group-edit-handler", text: translation.upcase)
|
||||
group = find_group(translation)
|
||||
expect(group).to have_text(translation)
|
||||
|
||||
within find_group(translation) do
|
||||
within group do
|
||||
attributes.each do |attribute|
|
||||
expect_attribute(**attribute)
|
||||
end
|
||||
@@ -232,7 +242,135 @@ module Components
|
||||
end
|
||||
|
||||
def expect_inactive(attribute)
|
||||
expect(inactive_drop).to have_css(".type-form-conf-attribute[data-key='#{attribute}']")
|
||||
expect(inactive_drop).to have_css(attribute_selector(attribute))
|
||||
end
|
||||
|
||||
def group_order
|
||||
page.within_test_selector("type-form-configuration-groups-container") do
|
||||
all(":scope > [data-group-key] .Box-header span.text-bold", visible: true).map(&:text)
|
||||
end
|
||||
end
|
||||
|
||||
def attribute_order(group_name)
|
||||
find_group(group_name).find("ul").all(":scope > li[data-attr-key]", visible: true).pluck("data-attr-key")
|
||||
end
|
||||
|
||||
def open_attribute_menu(attribute)
|
||||
open_menu("type-form-configuration-attribute-actions-#{attribute}")
|
||||
end
|
||||
|
||||
def open_query_menu(name)
|
||||
group_key = find_group(name)["data-group-key"]
|
||||
open_menu("type-form-configuration-query-actions-#{group_key}")
|
||||
end
|
||||
|
||||
def invoke_group_action(name, label)
|
||||
click_menu_action(-> { open_group_menu(name) }, label)
|
||||
wait_for_turbo
|
||||
end
|
||||
|
||||
def invoke_attribute_action(attribute, label)
|
||||
click_menu_action(-> { open_attribute_menu(attribute) }, label)
|
||||
wait_for_turbo
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def drop_container_for(target)
|
||||
inactive_list_selector = "[data-test-selector='type-form-configuration-inactive-list']"
|
||||
|
||||
if target.has_css?(inactive_list_selector, wait: 0)
|
||||
target.find(inactive_list_selector)
|
||||
else
|
||||
target.find(".Box ul")
|
||||
end
|
||||
end
|
||||
|
||||
def displayed_relation_filter_label(relation_filter)
|
||||
I18n.t("js.relation_labels.#{relation_filter}")
|
||||
end
|
||||
|
||||
def fill_group_name(name)
|
||||
input = page.find_test_selector("type-form-configuration-group-name-input", wait: 10)
|
||||
input.set(name)
|
||||
end
|
||||
|
||||
def open_group_menu(name)
|
||||
menu_id = nil
|
||||
|
||||
3.times do
|
||||
menu_button = menu_button_for(name)
|
||||
menu_id = menu_button[:"aria-controls"]
|
||||
menu_button.click
|
||||
|
||||
return menu_id if page.has_css?("##{menu_id}", visible: :all, wait: 2)
|
||||
rescue Selenium::WebDriver::Error::StaleElementReferenceError, Capybara::ElementNotFound
|
||||
next
|
||||
end
|
||||
|
||||
raise Capybara::ElementNotFound, "Unable to open menu #{menu_id.inspect}"
|
||||
end
|
||||
|
||||
def menu_button_for(name)
|
||||
group_key = find_group(name)["data-group-key"]
|
||||
page.find_test_selector("type-form-configuration-group-actions-#{group_key}")
|
||||
end
|
||||
|
||||
def open_menu(button_selector)
|
||||
menu_id = nil
|
||||
menu_button = nil
|
||||
|
||||
3.times do
|
||||
menu_button = page.find_test_selector(button_selector)
|
||||
menu_id = menu_button[:"aria-controls"]
|
||||
menu_button.click
|
||||
return menu_id if page.has_css?("##{menu_id}", visible: :all, wait: 2)
|
||||
rescue Capybara::Cuprite::MouseEventFailed
|
||||
menu_button&.trigger("click")
|
||||
return menu_id if page.has_css?("##{menu_id}", visible: :all, wait: 2)
|
||||
rescue Selenium::WebDriver::Error::StaleElementReferenceError, Capybara::ElementNotFound
|
||||
next
|
||||
end
|
||||
|
||||
raise Capybara::ElementNotFound, "Unable to open menu #{menu_id.inspect}"
|
||||
end
|
||||
|
||||
def click_menu_action(open_menu_callback, label)
|
||||
retry_block(args: { tries: 3 }) do
|
||||
menu_id = open_menu_callback.call
|
||||
menu = page.find("##{menu_id}", visible: :all)
|
||||
menu.first("[role='menuitem']", text: /\A#{Regexp.escape(label)}\z/, visible: :all).click
|
||||
end
|
||||
end
|
||||
|
||||
def save_group
|
||||
page.find_test_selector("type-form-configuration-group-save", wait: 10).click
|
||||
expect(page).to have_no_selector(page.test_selector("type-form-configuration-group-name-input"))
|
||||
end
|
||||
|
||||
def wait_for_turbo
|
||||
if using_cuprite?
|
||||
wait_for_reload
|
||||
else
|
||||
SeleniumHubWaiter.wait
|
||||
end
|
||||
end
|
||||
|
||||
def group_xpath(name)
|
||||
<<~XPATH.squish
|
||||
//*[@data-group-key]
|
||||
[.//span[contains(concat(' ', normalize-space(@class), ' '), ' text-bold ')
|
||||
and normalize-space()=#{xpath_literal(name)}]]
|
||||
XPATH
|
||||
end
|
||||
|
||||
def xpath_literal(value)
|
||||
if value.include?("'")
|
||||
parts = value.split("'").map { |part| "'#{part}'" }
|
||||
%(concat(#{parts.join(%q{, "'", })}))
|
||||
else
|
||||
"'#{value}'"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,6 +81,13 @@ module Components
|
||||
|
||||
def save
|
||||
find('[data-test-selector="spot-modal-wp-table-configuration-save-button"]').click
|
||||
expect_closed
|
||||
|
||||
if using_cuprite?
|
||||
wait_for_reload
|
||||
else
|
||||
SeleniumHubWaiter.wait
|
||||
end
|
||||
end
|
||||
|
||||
def cancel
|
||||
|
||||
@@ -44,7 +44,7 @@ module Pages
|
||||
end
|
||||
|
||||
def table_container
|
||||
container.find(".work-package-table")
|
||||
container.first(".work-package-table", wait: 10)
|
||||
end
|
||||
|
||||
def click_reference_inline_create
|
||||
|
||||
Reference in New Issue
Block a user