[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:
Behrokh Satarnejad
2026-05-13 12:56:18 +02:00
committed by GitHub
parent 55ec857669
commit d924c255cf
79 changed files with 4225 additions and 1166 deletions
@@ -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
@@ -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
@@ -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
@@ -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
%>
@@ -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
@@ -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
%>
@@ -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
+2 -3
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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)
+12 -9
View File
@@ -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
+23 -4
View File
@@ -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
View File
@@ -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:
-22
View File
@@ -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
View File
@@ -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"
-2
View File
@@ -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 -->
@@ -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">
@@ -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;
@@ -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;
}
}
@@ -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,
});
}
}
@@ -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
+257 -60
View File
@@ -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
+9
View File
@@ -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