From a3720713f6db816debd3e9ea8e57464185cc8f40 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 19 Oct 2023 10:50:30 +0200 Subject: [PATCH 001/218] =?UTF-8?q?[#49688]=20Individuelle=20Gruppen=20f?= =?UTF-8?q?=C3=BCr=20Projektattribute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://community.openproject.org/work_packages/49688 From 2a905987b234a59c6620d8f27d663aa78697b62a Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 30 Oct 2023 11:25:18 +0100 Subject: [PATCH 002/218] WIP: first steps introducing project attributes on project overview page, primerized project attribute editing via dialog --- app/models/project.rb | 2 + app/models/project_custom_field.rb | 2 + .../features/overview/overview.component.ts | 12 ++ .../grids/grid/page/grid-page.component.html | 19 +++- .../grids/grid/page/grid-page.component.sass | 5 + .../grids/grid/page/grid-page.component.ts | 4 + .../open_project/forms/autocompleter.html.erb | 8 +- .../show_component.html.erb | 12 ++ .../custom_field_value/show_component.rb | 57 ++++++++++ .../section/edit_dialog_component.html.erb | 38 +++++++ .../section/edit_dialog_component.rb | 88 ++++++++++++++ .../section/show_component.html.erb | 30 +++++ .../section/show_component.rb | 44 +++++++ .../sidebar_component.html.erb | 7 ++ .../project_attributes/sidebar_component.rb | 42 +++++++ .../overviews/overviews_controller.rb | 107 ++++++++++++++++++ .../forms/project/custom_value_form/base.rb | 62 ++++++++++ .../forms/project/custom_value_form/bool.rb | 41 +++++++ .../forms/project/custom_value_form/date.rb | 37 ++++++ .../forms/project/custom_value_form/float.rb | 37 ++++++ .../forms/project/custom_value_form/int.rb | 37 ++++++ .../custom_value_form/multi_select_list.rb | 72 ++++++++++++ .../multi_user_select_list.rb | 74 ++++++++++++ .../multi_version_select_list.rb | 73 ++++++++++++ .../custom_value_form/single_select_list.rb | 54 +++++++++ .../single_user_select_list.rb | 51 +++++++++ .../single_version_select_list.rb | 55 +++++++++ .../forms/project/custom_value_form/string.rb | 33 ++++++ .../forms/project/custom_value_form/text.rb | 38 +++++++ modules/overviews/config/routes.rb | 10 +- modules/overviews/lib/overviews/engine.rb | 13 ++- 31 files changed, 1153 insertions(+), 11 deletions(-) create mode 100644 modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb create mode 100644 modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb create mode 100644 modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb create mode 100644 modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb create mode 100644 modules/overviews/app/components/project_attributes/section/show_component.html.erb create mode 100644 modules/overviews/app/components/project_attributes/section/show_component.rb create mode 100644 modules/overviews/app/components/project_attributes/sidebar_component.html.erb create mode 100644 modules/overviews/app/components/project_attributes/sidebar_component.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/base.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/bool.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/date.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/float.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/int.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/single_select_list.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/string.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/text.rb diff --git a/app/models/project.rb b/app/models/project.rb index 38c30f42c6f9..e61525c2b038 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -41,6 +41,8 @@ class Project < ApplicationRecord # reserved identifiers RESERVED_IDENTIFIERS = %w(new).freeze + # belongs_to :project_type ---> mimic `work_package -> type -> custom_fields` structure ? + has_many :members, -> { # TODO: check whether this should # remain to be limited to User only diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 26e2d21eaa0e..101be6f0a389 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -27,6 +27,8 @@ #++ class ProjectCustomField < CustomField + # belongs_to :project_custom_field_section + def type_name :label_project_plural end diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index 93b900c79c21..139b1d753fdd 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -13,6 +13,18 @@ export class OverviewComponent extends GridPageComponent { return 'overviews'; } + protected isTurboFrameSidebarEnabled():boolean { + return true; + } + + protected turboFrameSidebarSrc():string { + return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier!}/attributes_sidebar`; + } + + protected turboFrameSidebarId():string { + return "project-attributes-sidebar"; + } + protected gridScopePath():string { return this.pathHelper.projectPath(this.currentProject.identifier!); } diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index 7a98248adcb9..202bcfc39fdd 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -21,6 +21,23 @@

- +
+
+ +
+
+ + + + + + + + + + + +
+
diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass index c9a867a68b69..f0353432f683 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass @@ -13,6 +13,11 @@ &--toolbar-items padding-right: 20px + &--sidebar + margin-top: 20px + margin-right: 20px + width: 324px + @media only screen and (max-width: $breakpoint-sm) .op-grid-page--title-container margin-left: 0 diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts index b8a738012aad..56cf054414df 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts @@ -30,6 +30,10 @@ export abstract class GridPageComponent implements OnInit, OnDestroy { public grid:GridResource; + protected isTurboFrameSidebarEnabled():boolean { + return false; + } + ngOnInit() { this.renderer.addClass(document.body, 'widget-grid-layout'); this diff --git a/lib/primer/open_project/forms/autocompleter.html.erb b/lib/primer/open_project/forms/autocompleter.html.erb index 9eb7bbcf65fc..bcd50431ed4f 100644 --- a/lib/primer/open_project/forms/autocompleter.html.erb +++ b/lib/primer/open_project/forms/autocompleter.html.erb @@ -2,8 +2,8 @@ <% if decorated_select? %> <%= render partial: '/augmented/autocomplete_select_decoration', locals: { - input_name: builder.field_name(@input.name), - input_id: builder.field_id(@input.name), + input_name: @autocomplete_options.fetch(:inputName) { |key| builder.field_name(@input.name) }, + input_id: @autocomplete_options.fetch(:inputId) { |key| builder.field_id(@input.name) }, select_options: select_options.map(&:to_h), multiple: @autocomplete_options.fetch(:multiple, false), key: @autocomplete_options.fetch(:resource, '') @@ -13,8 +13,8 @@ data: @autocomplete_options.delete(:data) { {} }, inputs: @autocomplete_options.merge( classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", - inputName: builder.field_name(@input.name), - inputValue: builder.object.send(@input.name), + inputName: @autocomplete_options.fetch(:inputName) { |key| builder.field_name(@input.name) }, + inputValue: @autocomplete_options.fetch(:inputValue) { |key| builder.object.send(@input.name) }, defaultData: 'true' ) %> diff --git a/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb b/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb new file mode 100644 index 000000000000..7b8062a52b62 --- /dev/null +++ b/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb @@ -0,0 +1,12 @@ +<%= + flex_layout(align_items: :flex_start, justify_content: :space_between) do |custom_field_value_container| + # temporarily using inline styles in order to align the content as desired + custom_field_value_container.with_column(mr: 2, style: "width: 130px;") do + render(Primer::Beta::Text.new(font_weight: :bold)) { @custom_field_value.custom_field.name } + end + + custom_field_value_container.with_column(flex: 1) do + render(Primer::Beta::Text.new()) { formated_value } + end + end +%> diff --git a/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb b/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb new file mode 100644 index 000000000000..a9f86db6c934 --- /dev/null +++ b/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb @@ -0,0 +1,57 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 ProjectAttributes + module Section + module CustomFieldValue + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize(custom_field_value:) + super + + @custom_field_value = custom_field_value + end + + private + + def formated_value + case @custom_field_value.custom_field.field_format + when "text" + ::OpenProject::TextFormatting::Renderer.format_text(@custom_field_value.typed_value) + when "date" + format_date(@custom_field_value.typed_value) + else + @custom_field_value.typed_value&.to_s + end + end + end + end + end +end diff --git a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb new file mode 100644 index 000000000000..a15d1256416e --- /dev/null +++ b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb @@ -0,0 +1,38 @@ + +<%= + content_tag("turbo-frame", id: "edit-project-attributes-dialog-frame") do + component_wrapper do + primer_form_with( + model: @project, + method: :put, + url: project_update_attributes_path(@project) + ) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 500px;")) do + flex_layout(my: 3) do |flex| + @custom_field_values.group_by { |cfv| cfv.custom_field_id }.sort.each do |custom_field_id, custom_field_values| + flex.with_row(mb: 2) do + render_custom_field_value_input(f, custom_field_id, custom_field_values) + end + end + end + end + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |collection1| + collection1.with_component(Primer::ButtonComponent.new( + data: { + 'close-dialog-id': "edit-project-attributes-dialog" + } + )) do + t("button_cancel") + end + collection1.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end + end + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb new file mode 100644 index 000000000000..471cd359bdd3 --- /dev/null +++ b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb @@ -0,0 +1,88 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 ProjectAttributes + module Section + class EditDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(project:, custom_field_values:) + super + + @project = project + @custom_field_values = custom_field_values + end + + def render_custom_field_value_input(form, custom_field_id, custom_field_values) + custom_field = CustomField.find(custom_field_id) + + if custom_field.multi_value? + render_multi_value_custom_field_input(form, custom_field, custom_field_values) + else + render_single_value_custom_field_input(form, custom_field, custom_field_values.first) + end + end + + def render_single_value_custom_field_input(form, custom_field, custom_field_value) + case custom_field.field_format + when "string" + render(Project::CustomValueForm::String.new(form, custom_field:, custom_field_value:, project: @project)) + when "text" + render(Project::CustomValueForm::Text.new(form, custom_field:, custom_field_value:, project: @project)) + when "int" + render(Project::CustomValueForm::Int.new(form, custom_field:, custom_field_value:, project: @project)) + when "float" + render(Project::CustomValueForm::Float.new(form, custom_field:, custom_field_value:, project: @project)) + when "list" + render(Project::CustomValueForm::SingleSelectList.new(form, custom_field:, custom_field_value:, project: @project)) + when "date" + render(Project::CustomValueForm::Date.new(form, custom_field:, custom_field_value:, project: @project)) + when "bool" + render(Project::CustomValueForm::Bool.new(form, custom_field:, custom_field_value:, project: @project)) + when "user" + render(Project::CustomValueForm::SingleUserSelectList.new(form, custom_field:, custom_field_value:, project: @project)) + when "version" + render(Project::CustomValueForm::SingleVersionSelectList.new(form, custom_field:, custom_field_value:, project: @project)) + end + end + + def render_multi_value_custom_field_input(form, custom_field, custom_field_values) + case custom_field.field_format + when "list" + render(Project::CustomValueForm::MultiSelectList.new(form, custom_field:, custom_field_values:, project: @project)) + when "user" + render(Project::CustomValueForm::MultiUserSelectList.new(form, custom_field:, custom_field_values:, project: @project)) + when "version" + render(Project::CustomValueForm::MultiVersionSelectList.new(form, custom_field:, custom_field_values:, project: @project)) + end + end + end + end +end diff --git a/modules/overviews/app/components/project_attributes/section/show_component.html.erb b/modules/overviews/app/components/project_attributes/section/show_component.html.erb new file mode 100644 index 000000000000..40c30b6328cf --- /dev/null +++ b/modules/overviews/app/components/project_attributes/section/show_component.html.erb @@ -0,0 +1,30 @@ +<%= + component_wrapper do + flex_layout(border: :bottom, pb: 2) do |details_container| + details_container.with_row(mb: 2) do + flex_layout(align_items: :center, justify_content: :space_between) do |heading| + heading.with_column(flex: 1) do + render(Primer::Beta::Heading.new(tag: :h4)) { "Basic info" } + end + + heading.with_column do + render(OpTurbo::OpPrimer::AsyncDialogComponent.new( + id: "edit-project-attributes-dialog", + src: project_attribute_section_dialog_path(@project), + size: :medium_portrait, + title: "Edit basic info", + button_icon: :pencil, + button_attributes: { scheme: :invisible } + )) + end + end + end + + @project.custom_field_values.each do |custom_field_value| + details_container.with_row(mb: 1) do + render(ProjectAttributes::Section::CustomFieldValue::ShowComponent.new(custom_field_value: custom_field_value)) + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_attributes/section/show_component.rb b/modules/overviews/app/components/project_attributes/section/show_component.rb new file mode 100644 index 000000000000..b6f127600e92 --- /dev/null +++ b/modules/overviews/app/components/project_attributes/section/show_component.rb @@ -0,0 +1,44 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 ProjectAttributes + module Section + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:) + super + + @project = project + end + end + end +end diff --git a/modules/overviews/app/components/project_attributes/sidebar_component.html.erb b/modules/overviews/app/components/project_attributes/sidebar_component.html.erb new file mode 100644 index 000000000000..ba2810aeba3d --- /dev/null +++ b/modules/overviews/app/components/project_attributes/sidebar_component.html.erb @@ -0,0 +1,7 @@ +<%= + content_tag("turbo-frame", id: "project-attributes-sidebar") do + component_wrapper do + render(ProjectAttributes::Section::ShowComponent.new(project: @project)) + end + end +%> diff --git a/modules/overviews/app/components/project_attributes/sidebar_component.rb b/modules/overviews/app/components/project_attributes/sidebar_component.rb new file mode 100644 index 000000000000..aa7fefa26fb2 --- /dev/null +++ b/modules/overviews/app/components/project_attributes/sidebar_component.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 ProjectAttributes + class SidebarComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:) + super + + @project = project + end + end +end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 09afdaf29573..27c12bc82050 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -1,14 +1,121 @@ module ::Overviews class OverviewsController < ::Grids::BaseInProjectController + include OpTurbo::ComponentStream + before_action :jump_to_project_menu_item menu_item :overview + def attributes_sidebar + render( + ProjectAttributes::SidebarComponent.new( + project: @project + ), + layout: false + ) + end + + def attribute_section_dialog + render( + ProjectAttributes::Section::EditDialogComponent.new( + project: @project, + custom_field_values: @project.custom_field_values + ), + layout: false + ) + end + + def update_attributes + # manual nested attributes update as the project model is not yet natively supporting it + # needs refactoring + + modified_custom_field_values = [] + has_errors = false + ActiveRecord::Base.transaction do + # transaction to rollback if any of the custom field values fails to save + project_attribute_params[:custom_field_values_attributes]&.each do |custom_value_id, attributes| + custom_value = CustomValue.find(custom_value_id.to_i) + + custom_value.value = attributes[:value] + unless custom_value.save + has_errors = true + end + modified_custom_field_values << custom_value + end + + project_attribute_params[:new_custom_field_values_attributes]&.each do |custom_field_id, attributes| + custom_value = CustomValue.new( + custom_field_id: custom_field_id.to_i, + value: attributes[:value], + customized_type: "Project", + customized_id: @project.id + ) + + unless custom_value.save + has_errors = true + end + modified_custom_field_values << custom_value + end + + # TODO: Cannot detect if all values are removed from a multi value custom field + # autocompleter does not send '_blank' as value when no option is selected as configured + project_attribute_params[:multi_custom_field_values_attributes]&.each do |custom_field_id, attributes| + # Detect removed values + @project.custom_values + .where(custom_field_id: custom_field_id.to_i) + .where.not(value: attributes[:values]) + .destroy_all + + attributes[:values]&.each do |value| + custom_value = CustomValue.find_or_initialize_by( + custom_field_id: custom_field_id.to_i, + value:, + customized_type: "Project", + customized_id: @project.id + ) + + unless custom_value.save + has_errors = true + end + modified_custom_field_values << custom_value + end + end + + if has_errors + update_via_turbo_stream( + component: ProjectAttributes::Section::EditDialogComponent.new( + project: @project, + custom_field_values: modified_custom_field_values + ) + ) + raise ActiveRecord::Rollback + else + update_via_turbo_stream( + component: ProjectAttributes::SidebarComponent.new( + project: @project + ) + ) + end + end + + respond_with_turbo_streams + end + def jump_to_project_menu_item if params[:jump] # try to redirect to the requested menu item redirect_to_project_menu_item(@project, params[:jump]) && return end end + + private + + def project_attribute_params + params.require(:project).permit( + custom_field_values_attributes: [:value], + new_custom_field_values_attributes: [:value], + multi_custom_field_values_attributes: [:custom_field_id, { values: [] }] + ) + end end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base.rb b/modules/overviews/app/forms/project/custom_value_form/base.rb new file mode 100644 index 000000000000..f01ba20e5715 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/base.rb @@ -0,0 +1,62 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Base < ApplicationForm + def initialize(custom_field:, custom_field_value:, project:) + @custom_field = custom_field + @custom_field_value = custom_field_value + @project = project + end + + def base_config + { + name:, + id:, + scope_name_to_model: false, + scope_id_to_model: false, + placeholder: @custom_field.name, + label: @custom_field.name, + value: @custom_field_value.value, + required: @custom_field.is_required?, + invalid: @custom_field_value.errors.any?, + validation_message: @custom_field_value.errors.any? ? @custom_field_value.errors.full_messages&.join(" ") : nil + } + end + + def name + if @custom_field_value.new_record? + "project[new_custom_field_values_attributes][#{@custom_field_value.custom_field_id}][value]" + else + "project[custom_field_values_attributes][#{@custom_field_value.id}][value]" + end + end + + def id + name.gsub(/[\[\]]/, "_") + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/bool.rb b/modules/overviews/app/forms/project/custom_value_form/bool.rb new file mode 100644 index 000000000000..7be79995201d --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/bool.rb @@ -0,0 +1,41 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Bool < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.check_box(**base_config) + end + + def base_config + super.merge({ + value: "1", + unchecked_value: "0", + checked: @custom_field_value&.typed_value == true + }) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/date.rb b/modules/overviews/app/forms/project/custom_value_form/date.rb new file mode 100644 index 000000000000..15f40f2fa593 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/date.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Date < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.text_field(**base_config) + end + + def base_config + super.merge({ type: "date" }) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/float.rb b/modules/overviews/app/forms/project/custom_value_form/float.rb new file mode 100644 index 000000000000..f68e6e550b9a --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/float.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Float < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.text_field(**base_config) + end + + def base_config + super.merge({ type: "number", step: :any }) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/int.rb b/modules/overviews/app/forms/project/custom_value_form/int.rb new file mode 100644 index 000000000000..903937c2981e --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/int.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Int < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.text_field(**base_config) + end + + def base_config + super.merge({ type: "number", step: 1 }) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb new file mode 100644 index 000000000000..33f1b3b47141 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb @@ -0,0 +1,72 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::MultiSelectList < Project::CustomValueForm::Base + def initialize(custom_field:, custom_field_values:, project:) + @custom_field = custom_field + @custom_field_values = custom_field_values + @project = project + end + + form do |custom_value_form| + custom_value_form.autocompleter(**base_config) do |list| + @custom_field.custom_options.each do |custom_option| + list.option( + label: custom_option.value, value: custom_option.id, + selected: @custom_field_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) + ) + end + end + end + + private + + def base_config + { + name:, + scope_name_to_model: false, + scope_id_to_model: false, # autocompleter does not respect scope_id_to_model = false + placeholder: @custom_field.name, + label: @custom_field.name, + required: @custom_field.is_required?, + include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected + autocomplete_options: { + multiple: true, + decorated: true, + inputId: id, + inputName: name, + }, + invalid: false, + validation_message: nil + } + end + + def name + "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb new file mode 100644 index 000000000000..7ef4b1864b49 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb @@ -0,0 +1,74 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::MultiUserSelectList < Project::CustomValueForm::Base + def initialize(custom_field:, custom_field_values:, project:) + @custom_field = custom_field + @custom_field_values = custom_field_values + @project = project + end + + form do |custom_value_form| + custom_value_form.autocompleter(**base_config) + end + + private + + def base_config + { + name:, + scope_name_to_model: false, + scope_id_to_model: false, # autocompleter does not respect scope_id_to_model = false + placeholder: @custom_field.name, + label: @custom_field.name, + required: @custom_field.is_required?, + include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected + autocomplete_options: { + multiple: true, + inputId: id, + placeholder: "Search for users", + resource: 'users', + # filters: [{ name: 'type', operator: '=', values: ['User'] }, + # { name: 'id', operator: '!', values: [::Queries::Filters::MeValue::KEY] }], + searchKey: 'any_name_attribute', + inputName: name, + inputValue: 4, + # focusDirectly: true, + # appendTo: 'body', + # disabled: @disabled + }, + invalid: false, + validation_message: nil + } + end + + def name + "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" + end + +end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb new file mode 100644 index 000000000000..c2ec51fb15c4 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb @@ -0,0 +1,73 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::MultiVersionSelectList < Project::CustomValueForm::Base + def initialize(custom_field:, custom_field_values:, project:) + @custom_field = custom_field + @custom_field_values = custom_field_values + @project = project + end + + form do |custom_value_form| + custom_value_form.autocompleter(**base_config) do |list| + @project.versions.each do |version| + list.option( + label: version.name, value: version.id, + selected: @custom_field_values.pluck(:value).map { |value| value&.to_i }.include?(version.id) + ) + end + end + end + + private + + def base_config + { + name:, + scope_name_to_model: false, + scope_id_to_model: false, # autocompleter does not respect scope_id_to_model = false + placeholder: @custom_field.name, + label: @custom_field.name, + required: @custom_field.is_required?, + include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected + autocomplete_options: { + multiple: true, + decorated: true, + inputId: id, + inputName: name, + }, + invalid: false, + validation_message: nil + } + end + + def name + "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" + end + +end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb new file mode 100644 index 000000000000..e05ff913db01 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb @@ -0,0 +1,54 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::SingleSelectList < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.autocompleter(**base_config) do |list| + @custom_field_value.custom_field.custom_options.each do |custom_option| + list.option( + label: custom_option.value, value: custom_option.id, + selected: custom_option.id == @custom_field_value.value&.to_i + ) + end + end + end + + def base_config + super.merge( + { + include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected + autocomplete_options: { + multiple: false, + decorated: true, + inputId: id, + inputName: name + } + } + ) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb new file mode 100644 index 000000000000..52471ef373c6 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb @@ -0,0 +1,51 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::SingleUserSelectList < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.autocompleter(**base_config) + end + + def base_config + super.merge({ + autocomplete_options: { + inputId: id, + placeholder: "Search for a user", + resource: 'users', + # filters: [{ name: 'type', operator: '=', values: ['User'] }, + # { name: 'id', operator: '!', values: [::Queries::Filters::MeValue::KEY] }], + searchKey: 'any_name_attribute', + inputName: name, + inputValue: @custom_field_value&.value&.to_i || '', + # focusDirectly: true, + # appendTo: 'body', + # disabled: @disabled + } + }) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb new file mode 100644 index 000000000000..5437fa2f82f4 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb @@ -0,0 +1,55 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::SingleVersionSelectList < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.autocompleter(**base_config) do |list| + @project.versions.each do |version| + list.option( + label: version.name, value: version.id, + selected: version.id == @custom_field_value.value&.to_i + ) + end + end + end + + def base_config + super.merge( + { + include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected + autocomplete_options: { + multiple: false, + decorated: true, + inputId: id, + inputName: name, + } + } + ) + end + +end diff --git a/modules/overviews/app/forms/project/custom_value_form/string.rb b/modules/overviews/app/forms/project/custom_value_form/string.rb new file mode 100644 index 000000000000..e301c10da3cd --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/string.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::String < Project::CustomValueForm::Base + form do |custom_value_form| + custom_value_form.text_field(**base_config) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/text.rb b/modules/overviews/app/forms/project/custom_value_form/text.rb new file mode 100644 index 000000000000..6c66afda1acf --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/text.rb @@ -0,0 +1,38 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Text < Project::CustomValueForm::Base + form do |custom_value_form| + # TODO: rich_text_area not working yet + # Uncaught DOMException: Failed to execute 'querySelector' on 'Element': '#project_project[new_custom_field_values_attributes][xyz][value]' is not a valid selector. + # --> rich_text_area is not using the configured id, which is not scoped to model via base_config + # --> ids with '[' ']' are not valid selectors + # using simple text area for now + custom_value_form.text_area(**base_config) + end +end diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index 81d5c8748677..64730d6247eb 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -1,6 +1,8 @@ OpenProject::Application.routes.draw do - get 'projects/:project_id', - to: "overviews/overviews#show", - as: :project_overview, - constraints: { format: :html, project_id: Regexp.new("(?!(#{Project::RESERVED_IDENTIFIERS.join('|')})$)(\\w|-)+") } + constraints(project_id: Regexp.new("(?!(#{Project::RESERVED_IDENTIFIERS.join('|')})$)(\\w|-)+")) do + get 'projects/:project_id', to: "overviews/overviews#show", as: :project_overview, format: :html + get 'projects/:project_id/attributes_sidebar', to: "overviews/overviews#attributes_sidebar", as: :project_attributes_sidebar, format: :html + get 'projects/:project_id/attribute_section_dialog', to: "overviews/overviews#attribute_section_dialog", as: :project_attribute_section_dialog, format: :html + put 'projects/:project_id/attributes', to: "overviews/overviews#update_attributes", as: :project_update_attributes, format: :html + end end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index 977be0cefb12..3463838867b7 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -46,12 +46,21 @@ class Engine < ::Rails::Engine Rails.application.reloader.to_prepare do OpenProject::AccessControl.permission(:view_project) .controller_actions - .push('overviews/overviews/show') + .push( + 'overviews/overviews/show', + 'overviews/overviews/attributes_sidebar', + 'overviews/overviews/attribute_section_dialog', + 'overviews/overviews/update_attributes' + ) OpenProject::AccessControl.map do |ac_map| ac_map.project_module nil do |map| map.permission :manage_overview, - { 'overviews/overviews': ['show'] }, + { 'overviews/overviews': + [ + 'show', 'attributes_sidebar', 'attribute_section_dialog', 'update_attributes' + ] + }, permissible_on: :project, require: :member end From cc886f28d262dae1265d5bf89d1f42488dfc9764 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 31 Oct 2023 16:02:27 +0100 Subject: [PATCH 003/218] introduce project custom field sections and project mapping --- app/models/project_custom_field.rb | 33 ++++++++++++- .../project_custom_field_project_mapping.rb | 45 ++++++++++++++++++ app/models/project_custom_field_section.rb | 34 ++++++++++++++ .../project_custom_field_section_mapping.rb | 47 +++++++++++++++++++ ...12_create_project_custom_field_sections.rb | 47 +++++++++++++++++++ ...e_project_custom_field_project_mappings.rb | 34 ++++++++++++++ .../section/edit_dialog_component.html.erb | 8 ++-- .../section/edit_dialog_component.rb | 17 +++++-- .../section/show_component.html.erb | 10 ++-- .../section/show_component.rb | 20 +++++++- .../sidebar_component.html.erb | 11 ++++- .../project_attributes/sidebar_component.rb | 1 - .../overviews/overviews_controller.rb | 14 +++++- modules/overviews/config/routes.rb | 9 ++-- 14 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 app/models/project_custom_field_project_mapping.rb create mode 100644 app/models/project_custom_field_section.rb create mode 100644 app/models/project_custom_field_section_mapping.rb create mode 100644 db/migrate/20231030103212_create_project_custom_field_sections.rb create mode 100644 db/migrate/20231031133334_create_project_custom_field_project_mappings.rb diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 101be6f0a389..faa827c52800 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -27,7 +27,18 @@ #++ class ProjectCustomField < CustomField - # belongs_to :project_custom_field_section + # don't pollute the custom_fields table with a section_id column which is only used by ProjectCustomFields + # use a separate mapping table instead + has_many :project_custom_field_section_mappings, + dependent: :destroy, + inverse_of: :project_custom_field, + class_name: 'ProjectCustomFieldSectionMapping', + foreign_key: 'custom_field_id' + + has_many :project_custom_field_sections, + through: :project_custom_field_section_mappings + + validate :exactly_one_section_mapped def type_name :label_project_plural @@ -40,4 +51,24 @@ def self.visible(user = User.current) where(visible: true) end end + + def exactly_one_section_mapped + unless project_custom_field_sections.count == 1 + errors.add(:base, "Exactly one section must be mapped to this custom field.") + end + end + + def project_custom_field_section + project_custom_field_sections.first + end + + def project_custom_field_section=(section) + # without this reload, a nil assignment is not recovered properly + project_custom_field_sections.reload + + ActiveRecord::Base.transaction do + project_custom_field_sections.clear + project_custom_field_sections << section + end + end end diff --git a/app/models/project_custom_field_project_mapping.rb b/app/models/project_custom_field_project_mapping.rb new file mode 100644 index 000000000000..e6d5c413875b --- /dev/null +++ b/app/models/project_custom_field_project_mapping.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class ProjectCustomFieldProjectMapping < ApplicationRecord + belongs_to :project + belongs_to :project_custom_field, class_name: 'ProjectCustomField', foreign_key: 'custom_field_id', + inverse_of: :project_custom_field_section_mappings + + # # Additionally to the database-level unique constraint, the application-level validation ensures that a + # # custom_field is associated with only one section + # validate :project_custom_field_uniqueness + + # private + + # def project_custom_field_uniqueness + # if ProjectCustomFieldSectionMapping.where(custom_field_id:).where.not(id:).exists? + # errors.add(:project_custom_field, "is already associated with another section") + # end + # end +end diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb new file mode 100644 index 000000000000..f849e376f098 --- /dev/null +++ b/app/models/project_custom_field_section.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class ProjectCustomFieldSection < ApplicationRecord + has_many :project_custom_field_section_mappings, dependent: :destroy + has_many :project_custom_fields, through: :project_custom_field_section_mappings + + acts_as_list +end diff --git a/app/models/project_custom_field_section_mapping.rb b/app/models/project_custom_field_section_mapping.rb new file mode 100644 index 000000000000..33bc324a1512 --- /dev/null +++ b/app/models/project_custom_field_section_mapping.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class ProjectCustomFieldSectionMapping < ApplicationRecord + belongs_to :project_custom_field_section + belongs_to :project_custom_field, class_name: 'ProjectCustomField', foreign_key: 'custom_field_id', + inverse_of: :project_custom_field_section_mappings + + # Additionally to the database-level unique constraint, the application-level validation ensures that a + # custom_field is associated with only one section + validate :project_custom_field_uniqueness + + acts_as_list scope: :project_custom_field_section + + private + + def project_custom_field_uniqueness + if ProjectCustomFieldSectionMapping.where(custom_field_id:).where.not(id:).exists? + errors.add(:project_custom_field, "is already associated with another section") + end + end +end diff --git a/db/migrate/20231030103212_create_project_custom_field_sections.rb b/db/migrate/20231030103212_create_project_custom_field_sections.rb new file mode 100644 index 000000000000..f79300b9d675 --- /dev/null +++ b/db/migrate/20231030103212_create_project_custom_field_sections.rb @@ -0,0 +1,47 @@ +class CreateProjectCustomFieldSections < ActiveRecord::Migration[7.0] + def up + create_table :project_custom_field_sections do |t| + t.integer :position + t.string :name + + t.timestamps + end + + # don't pollute the custom_fields table with a section_id column which is only used by ProjectCustomFields + # use a separate mapping table instead + create_table :project_custom_field_section_mappings do |t| + t.references :project_custom_field_section, foreign_key: true, index: { + name: 'index_project_cfs_mappings_on_section_id' + } + t.references :custom_field, foreign_key: true + t.integer :position + + t.timestamps + end + + # Add a unique constraint to ensure that a custom_field can only be added to one section + add_index :project_custom_field_section_mappings, :custom_field_id, unique: true, + name: 'index_project_cfs_mappings_on_custom_field_id' + + create_and_assign_default_section + end + + def down + drop_table :project_custom_field_section_mappings + drop_table :project_custom_field_sections + end + + private + + def create_and_assign_default_section + section = ProjectCustomFieldSection.create!( + name: "Project attributes" + ) + + mappings = ProjectCustomField.pluck(:id).map do |id| + { project_custom_field_section_id: section.id, custom_field_id: id } + end + + ProjectCustomFieldSectionMapping.create!(mappings) + end +end diff --git a/db/migrate/20231031133334_create_project_custom_field_project_mappings.rb b/db/migrate/20231031133334_create_project_custom_field_project_mappings.rb new file mode 100644 index 000000000000..424625295314 --- /dev/null +++ b/db/migrate/20231031133334_create_project_custom_field_project_mappings.rb @@ -0,0 +1,34 @@ +class CreateProjectCustomFieldProjectMappings < ActiveRecord::Migration[7.0] + def up + create_table :project_custom_field_project_mappings do |t| + t.references :custom_field, foreign_key: true, index: { + name: 'index_project_cf_project_mappings_on_custom_field_id' + } + t.references :project, foreign_key: true + + t.timestamps + end + + create_default_mapping + end + + def down + drop_table :project_custom_field_project_mappings + end + + private + + def create_default_mapping + project_ids = Project.pluck(:id) + custom_field_ids = ProjectCustomField.pluck(:id) + mappings = [] + + project_ids.each do |project_id| + custom_field_ids.each do |custom_field_id| + mappings << { custom_field_id:, project_id: } + end + end + + ProjectCustomFieldProjectMapping.create!(mappings) + end +end diff --git a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb index a15d1256416e..239a5d082052 100644 --- a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb @@ -1,16 +1,16 @@ <%= - content_tag("turbo-frame", id: "edit-project-attributes-dialog-frame") do + content_tag("turbo-frame", id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}-frame") do component_wrapper do primer_form_with( model: @project, method: :put, - url: project_update_attributes_path(@project) + url: project_update_attributes_path(project_id: @project.id, section_id: @project_custom_field_section.id), ) do |f| component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 500px;")) do flex_layout(my: 3) do |flex| - @custom_field_values.group_by { |cfv| cfv.custom_field_id }.sort.each do |custom_field_id, custom_field_values| + project_custom_field_values_of_section.group_by { |cfv| cfv.custom_field_id }.sort.each do |custom_field_id, custom_field_values| flex.with_row(mb: 2) do render_custom_field_value_input(f, custom_field_id, custom_field_values) end @@ -21,7 +21,7 @@ component_collection do |collection1| collection1.with_component(Primer::ButtonComponent.new( data: { - 'close-dialog-id': "edit-project-attributes-dialog" + 'close-dialog-id': "edit-project-attributes-dialog-#{@project_custom_field_section.id}" } )) do t("button_cancel") diff --git a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb index 471cd359bdd3..d608059e1e24 100644 --- a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb +++ b/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb @@ -33,13 +33,22 @@ class EditDialogComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(project:, custom_field_values:) + def initialize(project:, project_custom_field_section:, custom_field_values:) super @project = project + @project_custom_field_section = project_custom_field_section @custom_field_values = custom_field_values end + private + + def project_custom_field_values_of_section + @custom_field_values.to_a.select do |cfv| + @project_custom_field_section.project_custom_fields.pluck(:id).include?(cfv.custom_field_id) + end + end + def render_custom_field_value_input(form, custom_field_id, custom_field_values) custom_field = CustomField.find(custom_field_id) @@ -69,7 +78,8 @@ def render_single_value_custom_field_input(form, custom_field, custom_field_valu when "user" render(Project::CustomValueForm::SingleUserSelectList.new(form, custom_field:, custom_field_value:, project: @project)) when "version" - render(Project::CustomValueForm::SingleVersionSelectList.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::SingleVersionSelectList.new(form, custom_field:, custom_field_value:, + project: @project)) end end @@ -80,7 +90,8 @@ def render_multi_value_custom_field_input(form, custom_field, custom_field_value when "user" render(Project::CustomValueForm::MultiUserSelectList.new(form, custom_field:, custom_field_values:, project: @project)) when "version" - render(Project::CustomValueForm::MultiVersionSelectList.new(form, custom_field:, custom_field_values:, project: @project)) + render(Project::CustomValueForm::MultiVersionSelectList.new(form, custom_field:, custom_field_values:, + project: @project)) end end end diff --git a/modules/overviews/app/components/project_attributes/section/show_component.html.erb b/modules/overviews/app/components/project_attributes/section/show_component.html.erb index 40c30b6328cf..5946816e993e 100644 --- a/modules/overviews/app/components/project_attributes/section/show_component.html.erb +++ b/modules/overviews/app/components/project_attributes/section/show_component.html.erb @@ -4,15 +4,15 @@ details_container.with_row(mb: 2) do flex_layout(align_items: :center, justify_content: :space_between) do |heading| heading.with_column(flex: 1) do - render(Primer::Beta::Heading.new(tag: :h4)) { "Basic info" } + render(Primer::Beta::Heading.new(tag: :h4)) { @project_custom_field_section.name } end heading.with_column do render(OpTurbo::OpPrimer::AsyncDialogComponent.new( - id: "edit-project-attributes-dialog", - src: project_attribute_section_dialog_path(@project), + id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}", + src: project_attribute_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), size: :medium_portrait, - title: "Edit basic info", + title: "Edit #{ @project_custom_field_section.name }", button_icon: :pencil, button_attributes: { scheme: :invisible } )) @@ -20,7 +20,7 @@ end end - @project.custom_field_values.each do |custom_field_value| + project_custom_field_values_of_section.each do |custom_field_value| details_container.with_row(mb: 1) do render(ProjectAttributes::Section::CustomFieldValue::ShowComponent.new(custom_field_value: custom_field_value)) end diff --git a/modules/overviews/app/components/project_attributes/section/show_component.rb b/modules/overviews/app/components/project_attributes/section/show_component.rb index b6f127600e92..aea681c01589 100644 --- a/modules/overviews/app/components/project_attributes/section/show_component.rb +++ b/modules/overviews/app/components/project_attributes/section/show_component.rb @@ -26,7 +26,6 @@ # See COPYRIGHT and LICENSE files for more details. #++ - module ProjectAttributes module Section class ShowComponent < ApplicationComponent @@ -34,10 +33,27 @@ class ShowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:) + def initialize(project:, project_custom_field_section:) super @project = project + @project_custom_field_section = project_custom_field_section + end + + private + + def project_custom_field_values + active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping + .where(project_id: @project.id) + .pluck(:custom_field_id) + + CustomValue.where(custom_field_id: active_custom_field_ids_of_project, customized_id: @project.id) + end + + def project_custom_field_values_of_section + project_custom_field_values.select do |cfv| + @project_custom_field_section.project_custom_fields.pluck(:id).include?(cfv.custom_field_id) + end end end end diff --git a/modules/overviews/app/components/project_attributes/sidebar_component.html.erb b/modules/overviews/app/components/project_attributes/sidebar_component.html.erb index ba2810aeba3d..85a01ffe93e2 100644 --- a/modules/overviews/app/components/project_attributes/sidebar_component.html.erb +++ b/modules/overviews/app/components/project_attributes/sidebar_component.html.erb @@ -1,7 +1,16 @@ <%= content_tag("turbo-frame", id: "project-attributes-sidebar") do component_wrapper do - render(ProjectAttributes::Section::ShowComponent.new(project: @project)) + flex_layout do |sections_container| + ProjectCustomFieldSection.all.order(created_at: :asc).each do |project_custom_field_section| + sections_container.with_row(mb: 3) do + render(ProjectAttributes::Section::ShowComponent.new( + project: @project, + project_custom_field_section: project_custom_field_section + )) + end + end + end end end %> diff --git a/modules/overviews/app/components/project_attributes/sidebar_component.rb b/modules/overviews/app/components/project_attributes/sidebar_component.rb index aa7fefa26fb2..7ae51c2cbc0d 100644 --- a/modules/overviews/app/components/project_attributes/sidebar_component.rb +++ b/modules/overviews/app/components/project_attributes/sidebar_component.rb @@ -26,7 +26,6 @@ # See COPYRIGHT and LICENSE files for more details. #++ - module ProjectAttributes class SidebarComponent < ApplicationComponent include ApplicationHelper diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 27c12bc82050..fdceb3ffa1aa 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -16,10 +16,19 @@ def attributes_sidebar end def attribute_section_dialog + section = ProjectCustomFieldSection.find(params[:section_id]) + + active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping + .where(project_id: @project.id) + .pluck(:custom_field_id) + + custom_field_values = CustomValue.where(custom_field_id: active_custom_field_ids_of_project, customized_id: @project.id) + render( ProjectAttributes::Section::EditDialogComponent.new( project: @project, - custom_field_values: @project.custom_field_values + project_custom_field_section: section, + custom_field_values: ), layout: false ) @@ -29,6 +38,8 @@ def update_attributes # manual nested attributes update as the project model is not yet natively supporting it # needs refactoring + section = ProjectCustomFieldSection.find(params[:section_id]) + modified_custom_field_values = [] has_errors = false ActiveRecord::Base.transaction do @@ -85,6 +96,7 @@ def update_attributes update_via_turbo_stream( component: ProjectAttributes::Section::EditDialogComponent.new( project: @project, + project_custom_field_section: section, custom_field_values: modified_custom_field_values ) ) diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index 64730d6247eb..fb1db6b3c34f 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -1,8 +1,11 @@ OpenProject::Application.routes.draw do constraints(project_id: Regexp.new("(?!(#{Project::RESERVED_IDENTIFIERS.join('|')})$)(\\w|-)+")) do get 'projects/:project_id', to: "overviews/overviews#show", as: :project_overview, format: :html - get 'projects/:project_id/attributes_sidebar', to: "overviews/overviews#attributes_sidebar", as: :project_attributes_sidebar, format: :html - get 'projects/:project_id/attribute_section_dialog', to: "overviews/overviews#attribute_section_dialog", as: :project_attribute_section_dialog, format: :html - put 'projects/:project_id/attributes', to: "overviews/overviews#update_attributes", as: :project_update_attributes, format: :html + get 'projects/:project_id/attributes_sidebar', to: "overviews/overviews#attributes_sidebar", as: :project_attributes_sidebar, + format: :html + get 'projects/:project_id/attribute_section_dialog/:section_id', to: "overviews/overviews#attribute_section_dialog", + as: :project_attribute_section_dialog, format: :html + put 'projects/:project_id/attributes/:section_id', to: "overviews/overviews#update_attributes", as: :project_update_attributes, + format: :html end end From 7d83ab0517851eb04e5b8a9b5c3a4d624cbfbb47 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 21 Nov 2023 14:58:30 +0100 Subject: [PATCH 004/218] introducing project attributes management with generic drag and drop approach --- .../header_component.html.erb | 51 +++++ .../project_attributes/header_component.rb | 41 ++++ .../dialog_body_form_component.html.erb | 27 +++ .../section/dialog_body_form_component.rb | 59 ++++++ .../section/index_component.html.erb | 13 ++ .../section/index_component.rb | 69 +++++++ .../project_custom_field_component.html.erb | 24 +++ .../section/project_custom_field_component.rb | 85 +++++++++ .../section_component.html.erb | 57 ++++++ .../project_attributes/section_component.rb | 114 +++++++++++ ...roject_custom_field_sections_controller.rb | 106 +++++++++++ .../project_custom_fields_controller.rb | 98 ++++++++++ .../settings/projects_settings_controller.rb | 2 +- .../component_streams.rb | 69 +++++++ .../name_form.rb | 39 ++++ app/helpers/settings_helper.rb | 13 +- app/models/project_custom_field.rb | 4 + app/models/project_custom_field_section.rb | 8 + .../project_custom_fields/index.html.erb | 32 ++++ .../settings/projects_settings/show.html.erb | 4 +- config/initializers/menus.rb | 18 ++ config/locales/en.yml | 9 + config/routes.rb | 17 +- .../generic-drag-and-drop.controller.ts | 177 ++++++++++++++++++ 24 files changed, 1123 insertions(+), 13 deletions(-) create mode 100644 app/components/settings/project_attributes/header_component.html.erb create mode 100644 app/components/settings/project_attributes/header_component.rb create mode 100644 app/components/settings/project_attributes/section/dialog_body_form_component.html.erb create mode 100644 app/components/settings/project_attributes/section/dialog_body_form_component.rb create mode 100644 app/components/settings/project_attributes/section/index_component.html.erb create mode 100644 app/components/settings/project_attributes/section/index_component.rb create mode 100644 app/components/settings/project_attributes/section/project_custom_field_component.html.erb create mode 100644 app/components/settings/project_attributes/section/project_custom_field_component.rb create mode 100644 app/components/settings/project_attributes/section_component.html.erb create mode 100644 app/components/settings/project_attributes/section_component.rb create mode 100644 app/controllers/admin/settings/project_custom_field_sections_controller.rb create mode 100644 app/controllers/admin/settings/project_custom_fields_controller.rb create mode 100644 app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb create mode 100644 app/forms/project_custom_field_sections/name_form.rb create mode 100644 app/views/admin/settings/project_custom_fields/index.html.erb create mode 100644 frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts diff --git a/app/components/settings/project_attributes/header_component.html.erb b/app/components/settings/project_attributes/header_component.html.erb new file mode 100644 index 000000000000..0e244cdfd0a5 --- /dev/null +++ b/app/components/settings/project_attributes/header_component.html.erb @@ -0,0 +1,51 @@ +<%= + component_wrapper do + flex_layout do |header_container| + header_container.with_row do + flex_layout(justify_content: :space_between, align_items: :center) do |title_container| + + title_container.with_column(flex: 1) do + render(Primer::Beta::Heading.new(tag: :h1)) { t('settings.project_attributes.heading') } + end + + title_container.with_column do + flex_layout(justify_content: :space_between, align_items: :center) do |action_buttons_container| + + action_buttons_container.with_column(mr: 2) do + render(Primer::Beta::Button.new( + tag: :a, + href: new_custom_field_path(type: "ProjectCustomField"), + scheme: :primary, + data: { 'turbo-frame': 'true' } + )) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_attribute') + end + end + + action_buttons_container.with_column do + render(Primer::Alpha::Dialog.new( + id: "project-custom-field-section-dialog", title: t('settings.project_attributes.label_new_section'), + size: :medium_portrait + )) do |dialog| + dialog.with_show_button('aria-label': t('settings.project_attributes.label_new_section'), scheme: :default) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_section') + end + render(Settings::ProjectAttributes::Section::DialogBodyFormComponent.new()) + end + end + + end + end + + end + end + + header_container.with_row(mt: 1, pb: 3, mb: 2, border: :bottom) do + render(Primer::Beta::Text.new(color: :muted)) { t('settings.project_attributes.heading_description') } + end + + end + end +%> diff --git a/app/components/settings/project_attributes/header_component.rb b/app/components/settings/project_attributes/header_component.rb new file mode 100644 index 000000000000..5efac759681a --- /dev/null +++ b/app/components/settings/project_attributes/header_component.rb @@ -0,0 +1,41 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectAttributes + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize + super + end + end + end +end diff --git a/app/components/settings/project_attributes/section/dialog_body_form_component.html.erb b/app/components/settings/project_attributes/section/dialog_body_form_component.html.erb new file mode 100644 index 000000000000..49626ea97d57 --- /dev/null +++ b/app/components/settings/project_attributes/section/dialog_body_form_component.html.erb @@ -0,0 +1,27 @@ +<%= + component_wrapper do + primer_form_with(**form_config) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new) do + flex_layout(my: 3) do |modal_body| + modal_body.with_row do + render(ProjectCustomFieldSections::NameForm.new(f)) + end + end + end + + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |modal_footer| + modal_footer.with_component(Primer::ButtonComponent.new(data: { 'close-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}" })) do + t("button_cancel") + end + + modal_footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end + end + end + end + end +%> diff --git a/app/components/settings/project_attributes/section/dialog_body_form_component.rb b/app/components/settings/project_attributes/section/dialog_body_form_component.rb new file mode 100644 index 000000000000..32fdb04726f7 --- /dev/null +++ b/app/components/settings/project_attributes/section/dialog_body_form_component.rb @@ -0,0 +1,59 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectAttributes + module Section + class DialogBodyFormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(section: ProjectCustomFieldSection.new) + super + + @project_custom_field_section = section + end + + private + + def wrapper_uniq_by + @project_custom_field_section.id + end + + def form_config + { + model: @project_custom_field_section, + method: @project_custom_field_section.persisted? ? :put : :post, + url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path + } + end + end + end + end +end diff --git a/app/components/settings/project_attributes/section/index_component.html.erb b/app/components/settings/project_attributes/section/index_component.html.erb new file mode 100644 index 000000000000..f932849eb992 --- /dev/null +++ b/app/components/settings/project_attributes/section/index_component.html.erb @@ -0,0 +1,13 @@ +<%= + component_wrapper(data: wrapper_data_attributes) do + flex_layout(classes: 'dragula-container', data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do |flex| + @sections.each do |section| + flex.with_row( + data: draggable_item_config(section) + ) do + render(Settings::ProjectAttributes::SectionComponent.new(section:)) + end + end + end + end +%> diff --git a/app/components/settings/project_attributes/section/index_component.rb b/app/components/settings/project_attributes/section/index_component.rb new file mode 100644 index 000000000000..d72ad581994e --- /dev/null +++ b/app/components/settings/project_attributes/section/index_component.rb @@ -0,0 +1,69 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectAttributes + module Section + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(sections:) + super + + @sections = sections + end + + private + + def wrapper_data_attributes + { + controller: 'generic-drag-and-drop', + 'application-target': 'dynamic' + } + end + + def drop_target_config + { + 'is-drag-and-drop-target': true, + 'target-allowed-drag-type': 'section' # the type of dragged items which are allowed to be dropped in this target + } + end + + def draggable_item_config(section) + { + 'draggable-id': section.id, + 'draggable-type': 'section', + 'drop-url': drop_admin_settings_project_custom_field_section_path(section) + } + end + end + end + end +end diff --git a/app/components/settings/project_attributes/section/project_custom_field_component.html.erb b/app/components/settings/project_attributes/section/project_custom_field_component.html.erb new file mode 100644 index 000000000000..7b5a3bb3bce0 --- /dev/null +++ b/app/components/settings/project_attributes/section/project_custom_field_component.html.erb @@ -0,0 +1,24 @@ +<%= + component_wrapper do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: 'handle')) + end + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + "#{@project_custom_field.project_custom_field_section_mapping.position} #{@project_custom_field.name}" + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_project_custom_field_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + # delete_action_item(menu) + end + end + end + end +%> diff --git a/app/components/settings/project_attributes/section/project_custom_field_component.rb b/app/components/settings/project_attributes/section/project_custom_field_component.rb new file mode 100644 index 000000000000..78bb056482d3 --- /dev/null +++ b/app/components/settings/project_attributes/section/project_custom_field_component.rb @@ -0,0 +1,85 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectAttributes + module Section + class ProjectCustomFieldComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field:) + super + + @project_custom_field = project_custom_field + end + + private + + def edit_action_item(menu) + menu.with_item(label: t("label_edit"), + href: edit_custom_field_path(@project_custom_field)) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_actions(menu) + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") unless @project_custom_field.first? + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @project_custom_field.first? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") unless @project_custom_field.last? + unless @project_custom_field.last? + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), + "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_project_custom_field_path(@project_custom_field, move_to:), + form_arguments: { + method: :put, data: { 'turbo-stream': true } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + # def delete_action_item(menu) + # menu.with_item(label: t("text_destroy"), + # scheme: :danger, + # href: meeting_agenda_item_path(@meeting_agenda_item.meeting, @meeting_agenda_item), + # form_arguments: { + # method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } + # }) do |item| + # item.with_leading_visual_icon(icon: :trash) + # end + # end + end + end + end +end diff --git a/app/components/settings/project_attributes/section_component.html.erb b/app/components/settings/project_attributes/section_component.html.erb new file mode 100644 index 000000000000..6813016a9d3d --- /dev/null +++ b/app/components/settings/project_attributes/section_component.html.erb @@ -0,0 +1,57 @@ +<%= + component_wrapper do + render(Primer::Beta::BorderBox.new(mt: 3, data: { + id: @section.id, 'allowed-drop-type': 'custom-field' + }.merge(drag_and_drop_target_config))) do |component| + component.with_header(font_weight: :bold) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: 'handle')) + end + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + "#{@section.position} #{@section.name}" + end + end + end + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_section_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + if @project_custom_fields.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + end + end + end + actions_container.with_column do + render(Primer::Alpha::Dialog.new( + id: "project-custom-field-section-dialog#{@section.id}", title: t('settings.project_attributes.label_new_section'), + size: :medium_portrait + )) do |dialog| + render(Settings::ProjectAttributes::Section::DialogBodyFormComponent.new(section: @section)) + end + end + end + end + end + if @project_custom_fields.empty? + component.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } + end + else + @project_custom_fields.each do |project_custom_field| + component.with_row( + data: draggable_item_config(project_custom_field) + ) do + render(Settings::ProjectAttributes::Section::ProjectCustomFieldComponent.new(project_custom_field:)) + end + end + end + end + end +%> diff --git a/app/components/settings/project_attributes/section_component.rb b/app/components/settings/project_attributes/section_component.rb new file mode 100644 index 000000000000..caee30afd3c5 --- /dev/null +++ b/app/components/settings/project_attributes/section_component.rb @@ -0,0 +1,114 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectAttributes + class SectionComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(section:) + super + + @section = section + @project_custom_fields = section.project_custom_fields_ordered_by_postion_in_section + end + + private + + def wrapper_uniq_by + @section.id + end + + def drag_and_drop_target_config + { + 'is-drag-and-drop-target': true, + 'target-container-accessor': '.Box > ul', # the accessor of the container that contains the drag and drop items + 'target-id': @section.id, # the id of the target + 'target-allowed-drag-type': 'custom-field' # the type of dragged items which are allowed to be dropped in this target + } + end + + def draggable_item_config(project_custom_field) + { + 'draggable-id': project_custom_field.id, + 'draggable-type': 'custom-field', + 'drop-url': drop_admin_settings_project_custom_field_path(project_custom_field) + } + end + + def move_actions(menu) + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") unless @section.first? + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @section.first? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") unless @section.last? + unless @section.last? + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), + "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_project_custom_field_section_path(@section, move_to:), + form_arguments: { + method: :put, data: { 'turbo-stream': true } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def disabled_delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + disabled: true) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def edit_action_item(menu) + menu.with_item(label: t("text_edit"), + tag: :button, + content_arguments: { 'data-show-dialog-id': "project-custom-field-section-dialog#{@section.id}" }, + value: "") do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_project_custom_field_section_path(@section), + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end +end diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb new file mode 100644 index 000000000000..5f601238e954 --- /dev/null +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -0,0 +1,106 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin::Settings + class ProjectCustomFieldSectionsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::ProjectCustomFields::ComponentStreams + + before_action :set_project_custom_field_section, only: %i[move drop] + + def create + @project_custom_field_section = ProjectCustomFieldSection.new( + name: project_custom_field_section_params[:name], + position: 1 # show new sections at the top of the list, otherwise might not be visible to user + ) + + if @project_custom_field_section.save + update_header_via_turbo_stream # required to closed the dialog + update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + else + update_section_dialog_body_form_via_turbo_stream(section: @project_custom_field_section) + end + + respond_with_turbo_streams + end + + def update + @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) + + if @project_custom_field_section.update(project_custom_field_section_params) + update_section_via_turbo_stream(section: @project_custom_field_section) + else + update_section_dialog_body_form_via_turbo_stream(section: @project_custom_field_section) + end + + respond_with_turbo_streams + end + + def destroy + @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) + + if @project_custom_field_section.destroy + update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + end + + respond_with_turbo_streams + end + + def move + @project_custom_field_section.move_to = params[:move_to]&.to_sym + + if @project_custom_field_section.save + update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + end + + respond_with_turbo_streams + end + + def drop + if params[:position] == 'lowest' + @project_custom_field_section.move_to = :lowest + else + @project_custom_field_section.insert_at(params[:position].to_i) + end + + update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + + respond_with_turbo_streams + end + + private + + def set_project_custom_field_section + @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) + end + + def project_custom_field_section_params + params.require(:project_custom_field_section).permit(:name) + end + end +end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb new file mode 100644 index 000000000000..999c32fb75a8 --- /dev/null +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -0,0 +1,98 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin::Settings + class ProjectCustomFieldsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::ProjectCustomFields::ComponentStreams + + menu_item :project_custom_field_settings + + before_action :set_sections, only: %i[index move drop] + before_action :set_project_custom_field, only: %i[move drop] + + def default_breadcrumb + t(:label_project_attributes_plural) + end + + def index + respond_to :html + end + + def move + mapping = @project_custom_field.project_custom_field_section_mapping + mapping.move_to = params[:move_to]&.to_sym + + update_sections_via_turbo_stream(sections: @sections) + + respond_with_turbo_streams + end + + def drop + mapping = @project_custom_field.project_custom_field_section_mapping + + current_section = @project_custom_field.project_custom_field_section + current_section_id = current_section.id + new_section_id = params[:target_id].to_i + + if current_section_id != new_section_id + section_changed = true + old_section = current_section + mapping.remove_from_list + current_section = ProjectCustomFieldSection.find(params[:target_id].to_i) + @project_custom_field.project_custom_field_section = current_section + end + + mapping = @project_custom_field.reload.project_custom_field_section_mapping + + if params[:position] == 'lowest' + mapping.move_to = :lowest + else + mapping.insert_at(params[:position].to_i) + end + + update_section_via_turbo_stream(section: current_section) + + if section_changed + update_section_via_turbo_stream(section: old_section) + end + + respond_with_turbo_streams + end + + private + + def set_sections + @sections = ProjectCustomFieldSection.all + end + + def set_project_custom_field + @project_custom_field = ProjectCustomField.find(params[:id]) + end + end +end diff --git a/app/controllers/admin/settings/projects_settings_controller.rb b/app/controllers/admin/settings/projects_settings_controller.rb index c10212545cd8..dd5867cb8712 100644 --- a/app/controllers/admin/settings/projects_settings_controller.rb +++ b/app/controllers/admin/settings/projects_settings_controller.rb @@ -28,7 +28,7 @@ module Admin::Settings class ProjectsSettingsController < ::Admin::SettingsController - menu_item :settings_projects + menu_item :projects_settings def default_breadcrumb t(:label_project_plural) diff --git a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb new file mode 100644 index 000000000000..bd85e8404468 --- /dev/null +++ b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb @@ -0,0 +1,69 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module Settings + module ProjectCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_header_via_turbo_stream + update_via_turbo_stream( + component: ::Settings::ProjectAttributes::HeaderComponent.new + ) + end + + def update_section_via_turbo_stream(section:) + update_via_turbo_stream( + component: ::Settings::ProjectAttributes::SectionComponent.new( + section: + ) + ) + end + + def update_section_dialog_body_form_via_turbo_stream(section:) + update_via_turbo_stream( + component: ::Settings::ProjectAttributes::Section::DialogBodyFormComponent.new( + section: + ) + ) + end + + def update_sections_via_turbo_stream(sections:) + replace_via_turbo_stream( + component: ::Settings::ProjectAttributes::Section::IndexComponent.new( + sections: + ) + ) + end + end + end + end + end +end diff --git a/app/forms/project_custom_field_sections/name_form.rb b/app/forms/project_custom_field_sections/name_form.rb new file mode 100644 index 000000000000..caba64f6b8f0 --- /dev/null +++ b/app/forms/project_custom_field_sections/name_form.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class ProjectCustomFieldSections::NameForm < ApplicationForm + form do |project_custom_field_section_form| + project_custom_field_section_form.text_field( + name: :name, + placeholder: ProjectCustomFieldSection.human_attribute_name(:name), + label: ProjectCustomFieldSection.human_attribute_name(:name), + required: true, + autofocus: true + ) + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 23ad86cbbd8f..be092d69dc54 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -44,11 +44,6 @@ def system_settings_tabs controller: '/admin/settings/languages_settings', label: :label_languages }, - { - name: 'projects', - controller: '/admin/settings/projects_settings', - label: :label_project_plural - }, { name: 'attachments', controller: '/admin/settings/attachments_settings', @@ -199,11 +194,11 @@ def setting_block(setting, options = {}, &) private - def wrap_field_outer(options, &block) + def wrap_field_outer(options, &) if options[:label] == false - block.call + yield else - content_tag(:span, class: 'form--field-container', &block) + content_tag(:span, class: 'form--field-container', &) end end @@ -216,7 +211,7 @@ def build_settings_matrix_head(settings, options = {}) hidden_field_tag("settings[#{setting}][]", '') + I18n.t("setting_#{setting}") end - end.join.html_safe # rubocop:disable Rails/OutputSafety + end.join.html_safe end end diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index faa827c52800..33da45d776de 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -58,6 +58,10 @@ def exactly_one_section_mapped end end + def project_custom_field_section_mapping + project_custom_field_section_mappings.first + end + def project_custom_field_section project_custom_field_sections.first end diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index f849e376f098..5df3ab10531c 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -31,4 +31,12 @@ class ProjectCustomFieldSection < ApplicationRecord has_many :project_custom_fields, through: :project_custom_field_section_mappings acts_as_list + + validates :name, presence: true + + default_scope { order(:position) } + + def project_custom_fields_ordered_by_postion_in_section + project_custom_fields.reorder('project_custom_field_section_mappings.position ASC') + end end diff --git a/app/views/admin/settings/project_custom_fields/index.html.erb b/app/views/admin/settings/project_custom_fields/index.html.erb new file mode 100644 index 000000000000..36e546783258 --- /dev/null +++ b/app/views/admin/settings/project_custom_fields/index.html.erb @@ -0,0 +1,32 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> +
+ <%= render(Settings::ProjectAttributes::HeaderComponent.new()) %> + <%= render(Settings::ProjectAttributes::Section::IndexComponent.new(sections: @sections)) %> +
diff --git a/app/views/admin/settings/projects_settings/show.html.erb b/app/views/admin/settings/projects_settings/show.html.erb index e8d40db7d410..3fc54acc4bee 100644 --- a/app/views/admin/settings/projects_settings/show.html.erb +++ b/app/views/admin/settings/projects_settings/show.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= toolbar title: t(:label_project_plural) %> -<%= styled_form_tag(admin_settings_update_projects_path, method: :patch) do %> +<%= styled_form_tag(admin_settings_projects_path, method: :patch) do %>
<%= t('settings.projects.section_new_projects') %> @@ -67,7 +67,7 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> - +
<%= setting_multiselect(:enabled_projects_columns, column_choices) %>
diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 74b281889850..4cb63a198812 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -333,6 +333,24 @@ caption: Proc.new { Workflow.model_name.human }, parent: :admin_work_packages + menu.push :admin_projects_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_plural, + icon: 'projects' + + menu.push :project_custom_fields_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_attributes_plural, + parent: :admin_projects_settings + + menu.push :projects_settings, + { controller: '/admin/settings/projects_settings', action: :show }, + if: Proc.new { User.current.admin? }, + caption: :label_setting_plural, + parent: :admin_projects_settings + menu.push :custom_fields, { controller: '/custom_fields' }, if: Proc.new { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index e345a483c606..0978eeb62b1b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2032,7 +2032,9 @@ en: label_project_hierarchy: "Project hierarchy" label_project_new: "New project" label_project_plural: "Projects" + label_project_attributes_plural: "Project attributes" label_project_settings: "Project settings" + label_project_attributes_settings: "Project attributes settings" label_project_storage_plural: "File Storages" label_project_storage_project_folder: "File Storages: Project folders" label_projects_storage_information: "%{count} projects using %{storage} disk storage" @@ -2893,6 +2895,13 @@ en: delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification." other: "Other" passwords: "Passwords" + project_attributes: + heading: "Project attributes" + label_new_attribute: "Project attribute" + label_new_section: "Section" + label_section_actions: "Section actions" + heading_description: "These project attributes appear in the project overview of each project. You can add new attributes and group them into sections. Individual attributes can be enabled or disabled at a project level." + label_project_custom_field_actions: "Project attribute actions" projects: section_new_projects: "Settings for new projects" section_project_overview: "Settings for project overview list" diff --git a/config/routes.rb b/config/routes.rb index 072a44915688..9e1108ccf30e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -83,7 +83,7 @@ match '/account/register', action: 'register', via: %i[get post patch] get '/account/activate', action: 'activate' - match '/login', action: 'login', as: 'signin', via: %i[get post] + match '/login', action: 'login', as: 'signin', via: %i[get post] get '/login/internal', action: 'internal_login', as: 'internal_signin' get '/logout', action: 'logout', as: 'signout' @@ -417,6 +417,21 @@ resource :mail_notifications, controller: '/admin/settings/mail_notifications_settings', only: %i[show update] resource :api, controller: '/admin/settings/api_settings', only: %i[show update] resource :work_packages, controller: '/admin/settings/work_packages_settings', only: %i[show update] + resource :projects, controller: '/admin/settings/projects_settings', only: %i[show update] + resources :project_custom_fields, controller: '/admin/settings/project_custom_fields', + only: %i[index show update] do + member do + put :move + put :drop + end + end + resources :project_custom_field_sections, controller: '/admin/settings/project_custom_field_sections', + only: %i[create update destroy] do + member do + put :move + put :drop + end + end resource :working_days, controller: '/admin/settings/working_days_settings', only: %i[show update] resource :users, controller: '/admin/settings/users_settings', only: %i[show update] resource :date_format, controller: '/admin/settings/date_format_settings', only: %i[show update] diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts new file mode 100644 index 000000000000..2c5a68743709 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts @@ -0,0 +1,177 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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 * as Turbo from '@hotwired/turbo'; +import { Controller } from '@hotwired/stimulus'; +import { Drake } from 'dragula'; +import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { dropRight } from 'lodash'; + +export default class extends Controller { + drake:Drake|undefined; + targetConfigs:Object[]; + + containerTargets:Element[] + + connect() { + this.initDrake(); + } + + initDrake() { + this.setContainerTargetsAndConfigs(); + + // reinit drake if it already exists + if(this.drake){ + this.drake.destroy(); + } + + this.drake = dragula(this.containerTargets, + { + moves: (_el, _source, handle, _sibling) => !!handle?.classList.contains('octicon-grabber'), + accepts: (el?: Element | null, target?: Element | null, source?: Element | null, sibling?: Element | null) => this.accepts(el!, target!, source!, sibling!), + }, + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .on('drag', this.drag.bind(this)) + .on('drop', this.drop.bind(this)); + } + + reInitDrakeContainers() { + this.setContainerTargetsAndConfigs(); + if (this.drake) { + this.drake.containers = this.containerTargets; + } + } + + setContainerTargetsAndConfigs(): void { + const rawTargets = Array.from( + this.element.querySelectorAll('[data-is-drag-and-drop-target="true"]') + ) as Element[]; + this.targetConfigs = []; + let processedTargets: Element[] = []; + + rawTargets.forEach((target: Element) => { + let targetConfig: { + container: Element, + allowedDragType: string|null, + targetId: string|null + } = { + container: target, + allowedDragType: target.getAttribute('data-target-allowed-drag-type'), + targetId: target.getAttribute('data-target-id') + }; + + // if the target has a container accessor, use that as the container instead of the element itself + // we need this e.g. in Primer's boderbox component as we cannot add required data attributes to the ul element there + const containerAccessor = target.getAttribute('data-target-container-accessor'); + + if(containerAccessor){ + target = target.querySelector(containerAccessor) as Element + targetConfig.container = target + } + + // we need to save the targetConfigs separately as we need to pass the pure container elements to drake + // but need the configuration of the targets when dropping elements + this.targetConfigs.push(targetConfig); + + processedTargets = processedTargets.concat(target); + }); + + this.containerTargets = processedTargets; + } + + accepts(el:Element, target:Element, _source:Element|null, _sibling:Element|null) { + const targetConfig: any = this.targetConfigs.find((config: any) => config.container == target); + const acceptedDragType = targetConfig?.allowedDragType as string | undefined; + + const draggableType = el.getAttribute('data-draggable-type'); + + if (draggableType !== acceptedDragType) { + debugLog('Element is not allowed to be dropped here'); + return false; + } + + return true; + } + + drag(el:Element, _source:Element|null) { + // discover new target containers if they have been added to the DOM via Turbo streams + this.reInitDrakeContainers(); + } + + async drop(el:Element, target:Element, _source:Element|null, sibling:Element|null) { + const dropUrl = el.getAttribute('data-drop-url'); + + const targetPosition = Array.from(target.children).indexOf(el); + + const targetConfig: any = this.targetConfigs.find((config: any) => config.container == target); + const targetId = targetConfig?.targetId as string | undefined; + + const data = new FormData(); + + data.append('position', (targetPosition + 1).toString()); + + if(targetId){ + data.append('target_id', targetId.toString()); + } + + if(dropUrl){ + const response = await fetch(dropUrl, { + method: 'PUT', + body: data, + headers: { + 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement).content, + Accept: 'text/vnd.turbo-stream.html', + }, + credentials: 'same-origin', + }); + + if (!response.ok) { + debugLog('Failed to sort item'); + } else { + const text = await response.text(); + Turbo.renderStreamMessage(text); + // reinit drake containers as the DOM will be updated by Turbo streams + // otherwise the DOM references in the Drake instance will be outdated + setTimeout(()=> this.reInitDrakeContainers(), 100); + } + } + + if (this.drake) { + this.drake.cancel(true); // necessary to prevent "copying" behaviour + } + } + + disconnect() { + if (this.drake) { + this.drake.destroy(); + } + } +} From a7a3f98a30557f22ad27adbf53e59fb97eb875d8 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 23 Nov 2023 10:33:15 +0100 Subject: [PATCH 005/218] introducing project custom field create and edit --- .../header_component.html.erb | 2 +- .../section/project_custom_field_component.rb | 2 +- .../edit_form_header_component.html.erb | 50 +++++ .../edit_form_header_component.rb | 42 ++++ .../form_component.html.erb | 208 ++++++++++++++++++ .../project_custom_fields/form_component.rb | 55 +++++ .../new_form_header_component.html.erb | 50 +++++ .../new_form_header_component.rb | 40 ++++ .../project_custom_fields_controller.rb | 44 +++- app/models/permitted_params.rb | 1 + app/models/project_custom_field.rb | 8 + .../project_custom_fields/edit.html.erb | 32 +++ .../project_custom_fields/index.html.erb | 6 +- .../project_custom_fields/new.html.erb | 32 +++ config/locales/en.yml | 5 + config/routes.rb | 2 +- 16 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 app/components/settings/project_custom_fields/edit_form_header_component.html.erb create mode 100644 app/components/settings/project_custom_fields/edit_form_header_component.rb create mode 100644 app/components/settings/project_custom_fields/form_component.html.erb create mode 100644 app/components/settings/project_custom_fields/form_component.rb create mode 100644 app/components/settings/project_custom_fields/new_form_header_component.html.erb create mode 100644 app/components/settings/project_custom_fields/new_form_header_component.rb create mode 100644 app/views/admin/settings/project_custom_fields/edit.html.erb create mode 100644 app/views/admin/settings/project_custom_fields/new.html.erb diff --git a/app/components/settings/project_attributes/header_component.html.erb b/app/components/settings/project_attributes/header_component.html.erb index 0e244cdfd0a5..c126ee29a61c 100644 --- a/app/components/settings/project_attributes/header_component.html.erb +++ b/app/components/settings/project_attributes/header_component.html.erb @@ -14,7 +14,7 @@ action_buttons_container.with_column(mr: 2) do render(Primer::Beta::Button.new( tag: :a, - href: new_custom_field_path(type: "ProjectCustomField"), + href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), scheme: :primary, data: { 'turbo-frame': 'true' } )) do |button| diff --git a/app/components/settings/project_attributes/section/project_custom_field_component.rb b/app/components/settings/project_attributes/section/project_custom_field_component.rb index 78bb056482d3..fe70d54ecb14 100644 --- a/app/components/settings/project_attributes/section/project_custom_field_component.rb +++ b/app/components/settings/project_attributes/section/project_custom_field_component.rb @@ -44,7 +44,7 @@ def initialize(project_custom_field:) def edit_action_item(menu) menu.with_item(label: t("label_edit"), - href: edit_custom_field_path(@project_custom_field)) do |item| + href: edit_admin_settings_project_custom_field_path(@project_custom_field)) do |item| item.with_leading_visual_icon(icon: :pencil) end end diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb new file mode 100644 index 000000000000..c8b61164efae --- /dev/null +++ b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb @@ -0,0 +1,50 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> +<%= + flex_layout(pb: 2, border: :bottom) do |header_container| + header_container.with_row(mb: 2, flex_layout: true, align_items: :center) do |title_row_container| + title_row_container.with_column(mr: 2) do + render(Primer::Beta::IconButton.new( + tag: :a, + href: admin_settings_project_custom_fields_path, + scheme: :invisible, + icon: "arrow-left", + size: :large, + "aria-label": t("back") + )) + end + title_row_container.with_column(mr: 2) do + render(Primer::Beta::Heading.new(tag: :h1)) { @project_custom_field.name } + end + end + header_container.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :muted)) { t('settings.project_attributes.edit.description') } + end + end +%> diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.rb b/app/components/settings/project_custom_fields/edit_form_header_component.rb new file mode 100644 index 000000000000..5dc70d8f8a83 --- /dev/null +++ b/app/components/settings/project_custom_fields/edit_form_header_component.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectCustomFields + class EditFormHeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize(project_custom_field: ProjectCustomField.new) + super + + @project_custom_field = project_custom_field + end + end + end +end diff --git a/app/components/settings/project_custom_fields/form_component.html.erb b/app/components/settings/project_custom_fields/form_component.html.erb new file mode 100644 index 000000000000..17ed778aa263 --- /dev/null +++ b/app/components/settings/project_custom_fields/form_component.html.erb @@ -0,0 +1,208 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> +<%= @project_custom_field.errors.inspect %> +<%= primer_form_with(**form_config) do |f| %> +
+
+ <%= f.text_field :name, required: true, container_class: '-middle' %> +
+
+ <%= f.select :project_custom_field_section_id, + ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, + { container_class: '-slim' } + %> +
+
+ <%= f.select :field_format, + custom_field_formats_for_select(@project_custom_field), + { container_class: '-slim' }, + disabled: !@project_custom_field.new_record?, + data: { + action: 'admin--custom-fields#formatChanged', + 'admin--custom-fields-target': 'format' + } + %> +
+
+
+ <%= t(:label_min_max_length) %>
+ (<%= t(:text_min_max_length_info) %>) +
+
+
+ <%= f.number_field :min_length, + container_class: '-xslim', + data: { 'admin--custom-fields-target': 'length' } %> +
+
+ <%= f.number_field :max_length, + container_class: '-xslim', + data: { 'admin--custom-fields-target': 'length' } %> +
+
+
+ +
+ <%= f.text_field :regexp, + size: 50, + container_class: '-wide', + data: { 'admin--custom-fields-target': 'regexp' } %> + + <%= t(:text_regexp_info) %> + +
+ + <% if @project_custom_field.new_record? || @project_custom_field.list? || @project_custom_field.multi_value_possible? %> + + +
+ <%= I18n.t("activerecord.attributes.custom_field.possible_values") %> + <% if false %> +
+ + <%= link_to t('custom_fields.reorder_alphabetical'), + { action: :reorder_alphabetical }, + method: :post, + data: { confirm: t('custom_fields.reorder_confirmation') } %> + +
+ <% end %> + <% if false %> +
+ <%= render partial: "custom_fields/custom_options", locals: { custom_field: @project_custom_field, f: f } %> + +
+ <% end %> +
+ <% end %> +
+
+ <% if @project_custom_field.new_record? || !%w[text bool].include?(@project_custom_field.field_format) %> + <%= f.text_field :default_value, + id: 'custom_fields_default_value_text', + for: 'custom_fields_default_value_text', + data: { 'admin--custom-fields-target': 'defaultText' }, + container_class: '-wide' %> + <% end %> +
+ + +
+ <%= call_hook(:view_custom_fields_form_upper_box, custom_field: @project_custom_field, form: f) %> +
+ +
+ <% case @project_custom_field.class.name + when "WorkPackageCustomField" %> +
+ <%= f.check_box :is_required %> +
+
+ <%= f.check_box :is_for_all %> +
+
+ <%= f.check_box :is_filter %> +
+
+ <%= f.check_box :searchable, + data: { 'admin--custom-fields-target': 'searchable' }%> +
+ + <% when "UserCustomField" %> +
+ <%= f.check_box :is_required %> +
+
+ <%= f.check_box :visible %> +
+
+ <%= f.check_box :editable %> +
+ <% when "ProjectCustomField" %> +
+ <%= f.check_box :is_required %> +
+
+ <%= f.check_box :visible %> +
+
+ <%= f.check_box :searchable, + data: { 'admin--custom-fields-target': 'searchable' }%> +
+ <% when "TimeEntryCustomField" %> +
+ <%= f.check_box :is_required %> +
+ <% else %> +
+ <%= f.check_box :is_required %> +
+ <% end %> + <%= call_hook(:"view_custom_fields_form_#{@project_custom_field.type.to_s.underscore}", custom_field: @project_custom_field, form: f) %> + + <%= render(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do %> + <%= t("button_save") %> + <% end %> +
+<% end %> diff --git a/app/components/settings/project_custom_fields/form_component.rb b/app/components/settings/project_custom_fields/form_component.rb new file mode 100644 index 000000000000..bb4b1952dcc8 --- /dev/null +++ b/app/components/settings/project_custom_fields/form_component.rb @@ -0,0 +1,55 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectCustomFields + class FormComponent < ApplicationComponent + include ApplicationHelper + include CustomFieldsHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field: ProjectCustomField.new) + super + + @project_custom_field = project_custom_field + end + + private + + def form_config + { + model: @project_custom_field, + scope: :custom_field, + method: @project_custom_field.persisted? ? :put : :post, + url: @project_custom_field.persisted? ? admin_settings_project_custom_field_path(@project_custom_field) : admin_settings_project_custom_fields_path + } + end + end + end +end diff --git a/app/components/settings/project_custom_fields/new_form_header_component.html.erb b/app/components/settings/project_custom_fields/new_form_header_component.html.erb new file mode 100644 index 000000000000..19559fdeb6a0 --- /dev/null +++ b/app/components/settings/project_custom_fields/new_form_header_component.html.erb @@ -0,0 +1,50 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> +<%= + flex_layout(pb: 2, border: :bottom) do |header_container| + header_container.with_row(mb: 2, flex_layout: true, align_items: :center) do |title_row_container| + title_row_container.with_column(mr: 2) do + render(Primer::Beta::IconButton.new( + tag: :a, + href: admin_settings_project_custom_fields_path, + scheme: :invisible, + icon: "arrow-left", + size: :large, + "aria-label": t("back") + )) + end + title_row_container.with_column(mr: 2) do + render(Primer::Beta::Heading.new(tag: :h1)) { t('settings.project_attributes.new.heading') } + end + end + header_container.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :muted)) { t('settings.project_attributes.new.description') } + end + end +%> diff --git a/app/components/settings/project_custom_fields/new_form_header_component.rb b/app/components/settings/project_custom_fields/new_form_header_component.rb new file mode 100644 index 000000000000..1a138fb0e269 --- /dev/null +++ b/app/components/settings/project_custom_fields/new_form_header_component.rb @@ -0,0 +1,40 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectCustomFields + class NewFormHeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize + super + end + end + end +end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 999c32fb75a8..37c78fc49ae0 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -33,8 +33,8 @@ class ProjectCustomFieldsController < ::Admin::SettingsController menu_item :project_custom_field_settings - before_action :set_sections, only: %i[index move drop] - before_action :set_project_custom_field, only: %i[move drop] + before_action :set_sections, only: %i[index edit update move drop] + before_action :set_project_custom_field, only: %i[edit update move drop] def default_breadcrumb t(:label_project_attributes_plural) @@ -44,6 +44,42 @@ def index respond_to :html end + def new + @project_custom_field = ProjectCustomField.new + + respond_to :html + end + + def edit + respond_to :html + end + + def create + @project_custom_field = ProjectCustomField.new(project_custom_field_params.except(:project_custom_field_section_id)) + + if @project_custom_field.save + @project_custom_field.project_custom_field_section = ProjectCustomFieldSection.find(project_custom_field_params[:project_custom_field_section_id]) + + redirect_to admin_settings_project_custom_fields_path + else + render action: 'new' + end + end + + def update + if project_custom_field_params[:project_custom_field_section_id]&.to_i != @project_custom_field.project_custom_field_section_id + mapping = @project_custom_field.project_custom_field_section_mapping + mapping.remove_from_list + @project_custom_field.project_custom_field_section = ProjectCustomFieldSection.find(project_custom_field_params[:project_custom_field_section_id]) + end + + if @project_custom_field.update(project_custom_field_params.except(:project_custom_field_section_id)) + redirect_to admin_settings_project_custom_fields_path + else + render action: 'edit' + end + end + def move mapping = @project_custom_field.project_custom_field_section_mapping mapping.move_to = params[:move_to]&.to_sym @@ -94,5 +130,9 @@ def set_sections def set_project_custom_field @project_custom_field = ProjectCustomField.find(params[:id]) end + + def project_custom_field_params + permitted_params.custom_field + end end end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 00109e0d1ed5..3b8a5f23aa60 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -466,6 +466,7 @@ def self.permitted_attributes :possible_values, :multi_value, :content_right_to_left, + :project_custom_field_section_id, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } ], diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 33da45d776de..bd27c10091e7 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -38,6 +38,8 @@ class ProjectCustomField < CustomField has_many :project_custom_field_sections, through: :project_custom_field_section_mappings + accepts_nested_attributes_for :project_custom_field_sections + validate :exactly_one_section_mapped def type_name @@ -53,6 +55,8 @@ def self.visible(user = User.current) end def exactly_one_section_mapped + return unless persisted? + unless project_custom_field_sections.count == 1 errors.add(:base, "Exactly one section must be mapped to this custom field.") end @@ -66,6 +70,10 @@ def project_custom_field_section project_custom_field_sections.first end + def project_custom_field_section_id + project_custom_field_section&.id + end + def project_custom_field_section=(section) # without this reload, a nil assignment is not recovered properly project_custom_field_sections.reload diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb new file mode 100644 index 000000000000..852e11dcb026 --- /dev/null +++ b/app/views/admin/settings/project_custom_fields/edit.html.erb @@ -0,0 +1,32 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> + + <%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new(project_custom_field: @project_custom_field)) %> + <%= render(Settings::ProjectCustomFields::FormComponent.new(project_custom_field: @project_custom_field)) %> + diff --git a/app/views/admin/settings/project_custom_fields/index.html.erb b/app/views/admin/settings/project_custom_fields/index.html.erb index 36e546783258..dd9ec6c04150 100644 --- a/app/views/admin/settings/project_custom_fields/index.html.erb +++ b/app/views/admin/settings/project_custom_fields/index.html.erb @@ -27,6 +27,8 @@ See COPYRIGHT and LICENSE files for more details. ++#%>
- <%= render(Settings::ProjectAttributes::HeaderComponent.new()) %> - <%= render(Settings::ProjectAttributes::Section::IndexComponent.new(sections: @sections)) %> + + <%= render(Settings::ProjectAttributes::HeaderComponent.new()) %> + <%= render(Settings::ProjectAttributes::Section::IndexComponent.new(sections: @sections)) %> +
diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb new file mode 100644 index 000000000000..cee5c4fc715b --- /dev/null +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -0,0 +1,32 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> + + <%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new()) %> + <%= render(Settings::ProjectCustomFields::FormComponent.new(project_custom_field: @project_custom_field)) %> + diff --git a/config/locales/en.yml b/config/locales/en.yml index 0978eeb62b1b..86d30f31b5a8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2902,6 +2902,11 @@ en: label_section_actions: "Section actions" heading_description: "These project attributes appear in the project overview of each project. You can add new attributes and group them into sections. Individual attributes can be enabled or disabled at a project level." label_project_custom_field_actions: "Project attribute actions" + edit: + description: "Set the details of the project attribute that will be selectable by project administrators in each individual project settings." + new: + heading: "New attribute" + description: "Set the details of the project attribute that will be selectable by project administrators in each individual project settings." projects: section_new_projects: "Settings for new projects" section_project_overview: "Settings for project overview list" diff --git a/config/routes.rb b/config/routes.rb index 9e1108ccf30e..a03141d29346 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -419,7 +419,7 @@ resource :work_packages, controller: '/admin/settings/work_packages_settings', only: %i[show update] resource :projects, controller: '/admin/settings/projects_settings', only: %i[show update] resources :project_custom_fields, controller: '/admin/settings/project_custom_fields', - only: %i[index show update] do + only: %i[index show new create edit update] do member do put :move put :drop From 029c655b7f365d350782705866858b5e8126c159 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 24 Nov 2023 14:51:25 +0100 Subject: [PATCH 006/218] refactored datamodeling, reuse custom field forms in new project attribute context --- .../section/dialog_body_form_component.rb | 4 +- .../section/project_custom_field_component.rb | 85 -------- .../custom_field_row_component.html.erb} | 4 +- .../custom_field_row_component.rb | 84 ++++++++ .../index_component.html.erb | 4 +- .../index_component.rb | 58 +++--- .../show_component.html.erb} | 12 +- .../show_component.rb} | 34 ++-- .../edit_form_header_component.html.erb | 2 +- .../edit_form_header_component.rb | 4 +- .../form_component.html.erb | 192 ++---------------- .../project_custom_fields/form_component.rb | 15 +- .../header_component.html.erb | 2 +- .../header_component.rb | 2 +- app/contracts/custom_fields/base_contract.rb | 1 + ...roject_custom_field_sections_controller.rb | 20 +- .../project_custom_fields_controller.rb | 100 ++++----- .../component_streams.rb | 18 +- .../concerns/custom_fields/shared_actions.rb | 143 +++++++++++++ app/controllers/custom_fields_controller.rb | 113 +---------- ...ion_mapping.rb => custom_field_section.rb} | 20 +- app/models/permitted_params.rb | 2 +- app/models/project_custom_field.rb | 47 +---- .../project_custom_field_project_mapping.rb | 2 +- app/models/project_custom_field_section.rb | 14 +- .../project_custom_fields/edit.html.erb | 7 +- .../project_custom_fields/index.html.erb | 8 +- .../project_custom_fields/new.html.erb | 6 +- app/views/custom_fields/_form.html.erb | 12 ++ config/routes.rb | 5 +- ...12_create_project_custom_field_sections.rb | 47 ----- ...1123111357_create_custom_field_sections.rb | 36 ++++ .../generic-drag-and-drop.controller.ts | 10 +- 33 files changed, 464 insertions(+), 649 deletions(-) delete mode 100644 app/components/settings/project_attributes/section/project_custom_field_component.rb rename app/components/settings/{project_attributes/section/project_custom_field_component.html.erb => project_custom_field_sections/custom_field_row_component.html.erb} (84%) create mode 100644 app/components/settings/project_custom_field_sections/custom_field_row_component.rb rename app/components/settings/{project_attributes/section => project_custom_field_sections}/index_component.html.erb (64%) rename app/components/settings/{project_attributes/section => project_custom_field_sections}/index_component.rb (56%) rename app/components/settings/{project_attributes/section_component.html.erb => project_custom_field_sections/show_component.html.erb} (77%) rename app/components/settings/{project_attributes/section_component.rb => project_custom_field_sections/show_component.rb} (76%) rename app/components/settings/{project_attributes => project_custom_fields}/header_component.html.erb (97%) rename app/components/settings/{project_attributes => project_custom_fields}/header_component.rb (98%) create mode 100644 app/controllers/concerns/custom_fields/shared_actions.rb rename app/models/{project_custom_field_section_mapping.rb => custom_field_section.rb} (60%) delete mode 100644 db/migrate/20231030103212_create_project_custom_field_sections.rb create mode 100644 db/migrate/20231123111357_create_custom_field_sections.rb diff --git a/app/components/settings/project_attributes/section/dialog_body_form_component.rb b/app/components/settings/project_attributes/section/dialog_body_form_component.rb index 32fdb04726f7..b358be6d90de 100644 --- a/app/components/settings/project_attributes/section/dialog_body_form_component.rb +++ b/app/components/settings/project_attributes/section/dialog_body_form_component.rb @@ -34,10 +34,10 @@ class DialogBodyFormComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(section: ProjectCustomFieldSection.new) + def initialize(project_custom_field_section: ProjectCustomFieldSection.new) super - @project_custom_field_section = section + @project_custom_field_section = project_custom_field_section end private diff --git a/app/components/settings/project_attributes/section/project_custom_field_component.rb b/app/components/settings/project_attributes/section/project_custom_field_component.rb deleted file mode 100644 index fe70d54ecb14..000000000000 --- a/app/components/settings/project_attributes/section/project_custom_field_component.rb +++ /dev/null @@ -1,85 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2023 the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Settings - module ProjectAttributes - module Section - class ProjectCustomFieldComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - include OpTurbo::Streamable - - def initialize(project_custom_field:) - super - - @project_custom_field = project_custom_field - end - - private - - def edit_action_item(menu) - menu.with_item(label: t("label_edit"), - href: edit_admin_settings_project_custom_field_path(@project_custom_field)) do |item| - item.with_leading_visual_icon(icon: :pencil) - end - end - - def move_actions(menu) - move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") unless @project_custom_field.first? - move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @project_custom_field.first? - move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") unless @project_custom_field.last? - unless @project_custom_field.last? - move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), - "move-to-bottom") - end - end - - def move_action_item(menu, move_to, label_text, icon) - menu.with_item(label: label_text, - href: move_admin_settings_project_custom_field_path(@project_custom_field, move_to:), - form_arguments: { - method: :put, data: { 'turbo-stream': true } - }) do |item| - item.with_leading_visual_icon(icon:) - end - end - - # def delete_action_item(menu) - # menu.with_item(label: t("text_destroy"), - # scheme: :danger, - # href: meeting_agenda_item_path(@meeting_agenda_item.meeting, @meeting_agenda_item), - # form_arguments: { - # method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } - # }) do |item| - # item.with_leading_visual_icon(icon: :trash) - # end - # end - end - end - end -end diff --git a/app/components/settings/project_attributes/section/project_custom_field_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb similarity index 84% rename from app/components/settings/project_attributes/section/project_custom_field_component.html.erb rename to app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index 7b5a3bb3bce0..dbc4685be729 100644 --- a/app/components/settings/project_attributes/section/project_custom_field_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -7,7 +7,7 @@ end content_container.with_column do render(Primer::Beta::Text.new(font_weight: :bold)) do - "#{@project_custom_field.project_custom_field_section_mapping.position} #{@project_custom_field.name}" + "#{@project_custom_field.position_in_custom_field_section} #{@project_custom_field.name}" end end end @@ -16,7 +16,7 @@ menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_project_custom_field_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) - # delete_action_item(menu) + delete_action_item(menu) end end end diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..b36e95c8ef7f --- /dev/null +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,84 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Settings + module ProjectCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field:) + super + + @project_custom_field = project_custom_field + end + + private + + def edit_action_item(menu) + menu.with_item(label: t("label_edit"), + href: edit_admin_settings_project_custom_field_path(@project_custom_field), + data: { turbo: "false" }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_actions(menu) + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") unless @project_custom_field.first? + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @project_custom_field.first? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") unless @project_custom_field.last? + unless @project_custom_field.last? + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), + "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_project_custom_field_path(@project_custom_field, move_to:), + form_arguments: { + method: :put, data: { 'turbo-stream': true } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_project_custom_field_path(@project_custom_field), + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end +end diff --git a/app/components/settings/project_attributes/section/index_component.html.erb b/app/components/settings/project_custom_field_sections/index_component.html.erb similarity index 64% rename from app/components/settings/project_attributes/section/index_component.html.erb rename to app/components/settings/project_custom_field_sections/index_component.html.erb index f932849eb992..22c1f3bad99d 100644 --- a/app/components/settings/project_attributes/section/index_component.html.erb +++ b/app/components/settings/project_custom_field_sections/index_component.html.erb @@ -1,11 +1,11 @@ <%= component_wrapper(data: wrapper_data_attributes) do flex_layout(classes: 'dragula-container', data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do |flex| - @sections.each do |section| + @project_custom_field_sections.each do |section| flex.with_row( data: draggable_item_config(section) ) do - render(Settings::ProjectAttributes::SectionComponent.new(section:)) + render(Settings::ProjectCustomFieldSections::ShowComponent.new(project_custom_field_section: section)) end end end diff --git a/app/components/settings/project_attributes/section/index_component.rb b/app/components/settings/project_custom_field_sections/index_component.rb similarity index 56% rename from app/components/settings/project_attributes/section/index_component.rb rename to app/components/settings/project_custom_field_sections/index_component.rb index d72ad581994e..c4e6e2098d04 100644 --- a/app/components/settings/project_attributes/section/index_component.rb +++ b/app/components/settings/project_custom_field_sections/index_component.rb @@ -27,42 +27,40 @@ #++ module Settings - module ProjectAttributes - module Section - class IndexComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - include OpTurbo::Streamable + module ProjectCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable - def initialize(sections:) - super + def initialize(project_custom_field_sections:) + super - @sections = sections - end + @project_custom_field_sections = project_custom_field_sections + end - private + private - def wrapper_data_attributes - { - controller: 'generic-drag-and-drop', - 'application-target': 'dynamic' - } - end + def wrapper_data_attributes + { + controller: 'generic-drag-and-drop', + 'application-target': 'dynamic' + } + end - def drop_target_config - { - 'is-drag-and-drop-target': true, - 'target-allowed-drag-type': 'section' # the type of dragged items which are allowed to be dropped in this target - } - end + def drop_target_config + { + 'is-drag-and-drop-target': true, + 'target-allowed-drag-type': 'section' # the type of dragged items which are allowed to be dropped in this target + } + end - def draggable_item_config(section) - { - 'draggable-id': section.id, - 'draggable-type': 'section', - 'drop-url': drop_admin_settings_project_custom_field_section_path(section) - } - end + def draggable_item_config(section) + { + 'draggable-id': section.id, + 'draggable-type': 'section', + 'drop-url': drop_admin_settings_project_custom_field_section_path(section) + } end end end diff --git a/app/components/settings/project_attributes/section_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb similarity index 77% rename from app/components/settings/project_attributes/section_component.html.erb rename to app/components/settings/project_custom_field_sections/show_component.html.erb index 6813016a9d3d..fca775745c3c 100644 --- a/app/components/settings/project_attributes/section_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -1,7 +1,7 @@ <%= component_wrapper do render(Primer::Beta::BorderBox.new(mt: 3, data: { - id: @section.id, 'allowed-drop-type': 'custom-field' + id: @project_custom_field_section.id, 'allowed-drop-type': 'custom-field' }.merge(drag_and_drop_target_config))) do |component| component.with_header(font_weight: :bold) do flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| @@ -11,7 +11,7 @@ end content_container.with_column do render(Primer::Beta::Text.new(font_weight: :bold)) do - "#{@section.position} #{@section.name}" + "#{@project_custom_field_section.position} #{@project_custom_field_section.name}" end end end @@ -30,17 +30,17 @@ end actions_container.with_column do render(Primer::Alpha::Dialog.new( - id: "project-custom-field-section-dialog#{@section.id}", title: t('settings.project_attributes.label_new_section'), + id: "project-custom-field-section-dialog#{@project_custom_field_section.id}", title: t('settings.project_attributes.label_new_section'), size: :medium_portrait )) do |dialog| - render(Settings::ProjectAttributes::Section::DialogBodyFormComponent.new(section: @section)) + render(Settings::ProjectAttributes::Section::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) end end end end end if @project_custom_fields.empty? - component.with_row do + component.with_row(data: { 'empty-list-item': true }) do render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } end else @@ -48,7 +48,7 @@ component.with_row( data: draggable_item_config(project_custom_field) ) do - render(Settings::ProjectAttributes::Section::ProjectCustomFieldComponent.new(project_custom_field:)) + render(Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new(project_custom_field:)) end end end diff --git a/app/components/settings/project_attributes/section_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb similarity index 76% rename from app/components/settings/project_attributes/section_component.rb rename to app/components/settings/project_custom_field_sections/show_component.rb index caee30afd3c5..8001d6e75b27 100644 --- a/app/components/settings/project_attributes/section_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -27,30 +27,30 @@ #++ module Settings - module ProjectAttributes - class SectionComponent < ApplicationComponent + module ProjectCustomFieldSections + class ShowComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(section:) + def initialize(project_custom_field_section:) super - @section = section - @project_custom_fields = section.project_custom_fields_ordered_by_postion_in_section + @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_field_section.custom_fields.reorder(position_in_custom_field_section: :asc) end private def wrapper_uniq_by - @section.id + @project_custom_field_section.id end def drag_and_drop_target_config { 'is-drag-and-drop-target': true, 'target-container-accessor': '.Box > ul', # the accessor of the container that contains the drag and drop items - 'target-id': @section.id, # the id of the target + 'target-id': @project_custom_field_section.id, # the id of the target 'target-allowed-drag-type': 'custom-field' # the type of dragged items which are allowed to be dropped in this target } end @@ -64,10 +64,16 @@ def draggable_item_config(project_custom_field) end def move_actions(menu) - move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") unless @section.first? - move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @section.first? - move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") unless @section.last? - unless @section.last? + unless @project_custom_field_section.first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), + "move-to-top") + end + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @project_custom_field_section.first? + unless @project_custom_field_section.last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), + "chevron-down") + end + unless @project_custom_field_section.last? move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") end @@ -75,7 +81,7 @@ def move_actions(menu) def move_action_item(menu, move_to, label_text, icon) menu.with_item(label: label_text, - href: move_admin_settings_project_custom_field_section_path(@section, move_to:), + href: move_admin_settings_project_custom_field_section_path(@project_custom_field_section, move_to:), form_arguments: { method: :put, data: { 'turbo-stream': true } }) do |item| @@ -93,7 +99,7 @@ def disabled_delete_action_item(menu) def edit_action_item(menu) menu.with_item(label: t("text_edit"), tag: :button, - content_arguments: { 'data-show-dialog-id': "project-custom-field-section-dialog#{@section.id}" }, + content_arguments: { 'data-show-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}" }, value: "") do |item| item.with_leading_visual_icon(icon: :pencil) end @@ -102,7 +108,7 @@ def edit_action_item(menu) def delete_action_item(menu) menu.with_item(label: t("text_destroy"), scheme: :danger, - href: admin_settings_project_custom_field_section_path(@section), + href: admin_settings_project_custom_field_section_path(@project_custom_field_section), form_arguments: { method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } }) do |item| diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb index c8b61164efae..3c223eb55472 100644 --- a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb +++ b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb @@ -40,7 +40,7 @@ See COPYRIGHT and LICENSE files for more details. )) end title_row_container.with_column(mr: 2) do - render(Primer::Beta::Heading.new(tag: :h1)) { @project_custom_field.name } + render(Primer::Beta::Heading.new(tag: :h1)) { @custom_field.name } end end header_container.with_row(mb: 2) do diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.rb b/app/components/settings/project_custom_fields/edit_form_header_component.rb index 5dc70d8f8a83..2e6450b489a5 100644 --- a/app/components/settings/project_custom_fields/edit_form_header_component.rb +++ b/app/components/settings/project_custom_fields/edit_form_header_component.rb @@ -32,10 +32,10 @@ class EditFormHeaderComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - def initialize(project_custom_field: ProjectCustomField.new) + def initialize(custom_field:) super - @project_custom_field = project_custom_field + @custom_field = custom_field end end end diff --git a/app/components/settings/project_custom_fields/form_component.html.erb b/app/components/settings/project_custom_fields/form_component.html.erb index 17ed778aa263..700c6c860e31 100644 --- a/app/components/settings/project_custom_fields/form_component.html.erb +++ b/app/components/settings/project_custom_fields/form_component.html.erb @@ -26,183 +26,19 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= @project_custom_field.errors.inspect %> -<%= primer_form_with(**form_config) do |f| %> -
-
- <%= f.text_field :name, required: true, container_class: '-middle' %> -
-
- <%= f.select :project_custom_field_section_id, - ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, - { container_class: '-slim' } - %> -
-
- <%= f.select :field_format, - custom_field_formats_for_select(@project_custom_field), - { container_class: '-slim' }, - disabled: !@project_custom_field.new_record?, - data: { - action: 'admin--custom-fields#formatChanged', - 'admin--custom-fields-target': 'format' - } - %> -
-
-
- <%= t(:label_min_max_length) %>
- (<%= t(:text_min_max_length_info) %>) -
-
-
- <%= f.number_field :min_length, - container_class: '-xslim', - data: { 'admin--custom-fields-target': 'length' } %> -
-
- <%= f.number_field :max_length, - container_class: '-xslim', - data: { 'admin--custom-fields-target': 'length' } %> -
-
-
- -
- <%= f.text_field :regexp, - size: 50, - container_class: '-wide', - data: { 'admin--custom-fields-target': 'regexp' } %> - - <%= t(:text_regexp_info) %> - -
- - <% if @project_custom_field.new_record? || @project_custom_field.list? || @project_custom_field.multi_value_possible? %> - - -
- <%= I18n.t("activerecord.attributes.custom_field.possible_values") %> - <% if false %> -
- - <%= link_to t('custom_fields.reorder_alphabetical'), - { action: :reorder_alphabetical }, - method: :post, - data: { confirm: t('custom_fields.reorder_confirmation') } %> - -
- <% end %> - <% if false %> -
- <%= render partial: "custom_fields/custom_options", locals: { custom_field: @project_custom_field, f: f } %> - -
- <% end %> -
- <% end %> -
-
- <% if @project_custom_field.new_record? || !%w[text bool].include?(@project_custom_field.field_format) %> - <%= f.text_field :default_value, - id: 'custom_fields_default_value_text', - for: 'custom_fields_default_value_text', - data: { 'admin--custom-fields-target': 'defaultText' }, - container_class: '-wide' %> - <% end %> -
- - -
- <%= call_hook(:view_custom_fields_form_upper_box, custom_field: @project_custom_field, form: f) %> -
- -
- <% case @project_custom_field.class.name - when "WorkPackageCustomField" %> -
- <%= f.check_box :is_required %> -
-
- <%= f.check_box :is_for_all %> -
-
- <%= f.check_box :is_filter %> -
-
- <%= f.check_box :searchable, - data: { 'admin--custom-fields-target': 'searchable' }%> -
- - <% when "UserCustomField" %> -
- <%= f.check_box :is_required %> -
-
- <%= f.check_box :visible %> -
-
- <%= f.check_box :editable %> -
- <% when "ProjectCustomField" %> -
- <%= f.check_box :is_required %> -
-
- <%= f.check_box :visible %> -
-
- <%= f.check_box :searchable, - data: { 'admin--custom-fields-target': 'searchable' }%> -
- <% when "TimeEntryCustomField" %> -
- <%= f.check_box :is_required %> -
- <% else %> -
- <%= f.check_box :is_required %> -
- <% end %> - <%= call_hook(:"view_custom_fields_form_#{@project_custom_field.type.to_s.underscore}", custom_field: @project_custom_field, form: f) %> - - <%= render(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do %> - <%= t("button_save") %> +<%= content_tag(:div, data: { + controller: 'admin--custom-fields', + 'application-target': 'dynamic', + 'admin--custom-fields-format-value': @custom_field.field_format + }) do %> + <%= error_messages_for 'custom_field' %> + + <%= labelled_tabular_form_for(@custom_field, **form_config) do |f| %> + <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> + <% if @custom_field.new_record? %> + <%= hidden_field_tag 'type', @custom_field.type %> <% end %> -
+ <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> + <% end %> <% end %> + diff --git a/app/components/settings/project_custom_fields/form_component.rb b/app/components/settings/project_custom_fields/form_component.rb index bb4b1952dcc8..211c8191756b 100644 --- a/app/components/settings/project_custom_fields/form_component.rb +++ b/app/components/settings/project_custom_fields/form_component.rb @@ -31,23 +31,24 @@ module ProjectCustomFields class FormComponent < ApplicationComponent include ApplicationHelper include CustomFieldsHelper + include StimulusHelper + include ErrorMessageHelper + include OpenProject::FormTagHelper include OpPrimer::ComponentHelpers - include OpTurbo::Streamable - def initialize(project_custom_field: ProjectCustomField.new) + def initialize(custom_field: ProjectCustomField.new) super - @project_custom_field = project_custom_field + @custom_field = custom_field end private def form_config { - model: @project_custom_field, - scope: :custom_field, - method: @project_custom_field.persisted? ? :put : :post, - url: @project_custom_field.persisted? ? admin_settings_project_custom_field_path(@project_custom_field) : admin_settings_project_custom_fields_path + as: :custom_field, + url: @custom_field.persisted? ? admin_settings_project_custom_field_path(@custom_field) : admin_settings_project_custom_fields_path, + html: { method: @custom_field.persisted? ? :put : :post, id: 'custom_field_form' } } end end diff --git a/app/components/settings/project_attributes/header_component.html.erb b/app/components/settings/project_custom_fields/header_component.html.erb similarity index 97% rename from app/components/settings/project_attributes/header_component.html.erb rename to app/components/settings/project_custom_fields/header_component.html.erb index c126ee29a61c..8a590d5411f8 100644 --- a/app/components/settings/project_attributes/header_component.html.erb +++ b/app/components/settings/project_custom_fields/header_component.html.erb @@ -16,7 +16,7 @@ tag: :a, href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), scheme: :primary, - data: { 'turbo-frame': 'true' } + data: { turbo: "false"} )) do |button| button.with_leading_visual_icon(icon: :plus) t('settings.project_attributes.label_new_attribute') diff --git a/app/components/settings/project_attributes/header_component.rb b/app/components/settings/project_custom_fields/header_component.rb similarity index 98% rename from app/components/settings/project_attributes/header_component.rb rename to app/components/settings/project_custom_fields/header_component.rb index 5efac759681a..74477d49f406 100644 --- a/app/components/settings/project_attributes/header_component.rb +++ b/app/components/settings/project_custom_fields/header_component.rb @@ -27,7 +27,7 @@ #++ module Settings - module ProjectAttributes + module ProjectCustomFields class HeaderComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index 5721e5c400c0..6ebe189a908c 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -47,5 +47,6 @@ class BaseContract < ::ModelContract attribute :possible_values attribute :multi_value attribute :content_right_to_left + attribute :custom_field_section_id end end diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb index 5f601238e954..041749c9e0a7 100644 --- a/app/controllers/admin/settings/project_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -41,9 +41,9 @@ def create if @project_custom_field_section.save update_header_via_turbo_stream # required to closed the dialog - update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - update_section_dialog_body_form_via_turbo_stream(section: @project_custom_field_section) + update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: @project_custom_field_section) end respond_with_turbo_streams @@ -53,9 +53,9 @@ def update @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) if @project_custom_field_section.update(project_custom_field_section_params) - update_section_via_turbo_stream(section: @project_custom_field_section) + update_section_via_turbo_stream(project_custom_field_section: @project_custom_field_section) else - update_section_dialog_body_form_via_turbo_stream(section: @project_custom_field_section) + update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: @project_custom_field_section) end respond_with_turbo_streams @@ -65,7 +65,7 @@ def destroy @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) if @project_custom_field_section.destroy - update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) end respond_with_turbo_streams @@ -75,20 +75,16 @@ def move @project_custom_field_section.move_to = params[:move_to]&.to_sym if @project_custom_field_section.save - update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) end respond_with_turbo_streams end def drop - if params[:position] == 'lowest' - @project_custom_field_section.move_to = :lowest - else - @project_custom_field_section.insert_at(params[:position].to_i) - end + @project_custom_field_section.insert_at(params[:position].to_i) - update_sections_via_turbo_stream(sections: ProjectCustomFieldSection.all) + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) respond_with_turbo_streams end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 37c78fc49ae0..ded7af72c9da 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -28,13 +28,16 @@ module Admin::Settings class ProjectCustomFieldsController < ::Admin::SettingsController + include CustomFields::SharedActions include OpTurbo::ComponentStream include Admin::Settings::ProjectCustomFields::ComponentStreams menu_item :project_custom_field_settings - before_action :set_sections, only: %i[index edit update move drop] - before_action :set_project_custom_field, only: %i[edit update move drop] + before_action :set_sections, only: %i[show index edit update move drop] + before_action :find_custom_field, only: %i(show edit update destroy delete_option reorder_alphabetical move drop) + before_action :prepare_custom_option_position, only: %i(update create) + before_action :find_custom_option, only: :delete_option def default_breadcrumb t(:label_project_attributes_plural) @@ -44,95 +47,82 @@ def index respond_to :html end - def new - @project_custom_field = ProjectCustomField.new - - respond_to :html + def show + # quick fixing redirect issue from perform_update + # perform_update is always redirecting to the show action altough configured otherwise + render :edit end - def edit - respond_to :html - end - - def create - @project_custom_field = ProjectCustomField.new(project_custom_field_params.except(:project_custom_field_section_id)) - - if @project_custom_field.save - @project_custom_field.project_custom_field_section = ProjectCustomFieldSection.find(project_custom_field_params[:project_custom_field_section_id]) + def new + @custom_field = ProjectCustomField.new - redirect_to admin_settings_project_custom_fields_path - else - render action: 'new' - end + respond_to :html end - def update - if project_custom_field_params[:project_custom_field_section_id]&.to_i != @project_custom_field.project_custom_field_section_id - mapping = @project_custom_field.project_custom_field_section_mapping - mapping.remove_from_list - @project_custom_field.project_custom_field_section = ProjectCustomFieldSection.find(project_custom_field_params[:project_custom_field_section_id]) - end - - if @project_custom_field.update(project_custom_field_params.except(:project_custom_field_section_id)) - redirect_to admin_settings_project_custom_fields_path - else - render action: 'edit' - end - end + def edit; end def move - mapping = @project_custom_field.project_custom_field_section_mapping - mapping.move_to = params[:move_to]&.to_sym + # prototyopical implementation + # needs refactoring via update service + @custom_field.move_to = params[:move_to]&.to_sym - update_sections_via_turbo_stream(sections: @sections) + update_sections_via_turbo_stream(project_custom_field_sections: @custom_field_sections) respond_with_turbo_streams end def drop - mapping = @project_custom_field.project_custom_field_section_mapping - - current_section = @project_custom_field.project_custom_field_section + # prototyopical implementation + # needs refactoring via update service + current_section = @custom_field.project_custom_field_section current_section_id = current_section.id new_section_id = params[:target_id].to_i if current_section_id != new_section_id section_changed = true old_section = current_section - mapping.remove_from_list current_section = ProjectCustomFieldSection.find(params[:target_id].to_i) - @project_custom_field.project_custom_field_section = current_section + @custom_field.remove_from_list + @custom_field.update(project_custom_field_section: current_section) end - mapping = @project_custom_field.reload.project_custom_field_section_mapping - - if params[:position] == 'lowest' - mapping.move_to = :lowest - else - mapping.insert_at(params[:position].to_i) - end + @custom_field.insert_at(params[:position].to_i) - update_section_via_turbo_stream(section: current_section) + update_section_via_turbo_stream(project_custom_field_section: current_section) if section_changed - update_section_via_turbo_stream(section: old_section) + update_section_via_turbo_stream(project_custom_field_section: old_section) end respond_with_turbo_streams end + def destroy + @custom_field.destroy + + update_section_via_turbo_stream(project_custom_field_section: @custom_field.project_custom_field_section) + + respond_with_turbo_streams + end + private - def set_sections - @sections = ProjectCustomFieldSection.all + def edit_path(id:) + admin_settings_project_custom_field_path(id:) end - def set_project_custom_field - @project_custom_field = ProjectCustomField.find(params[:id]) + def index_path(params = {}) + admin_settings_project_custom_fields_path(**params) + end + + def set_sections + @project_custom_field_sections = ProjectCustomFieldSection.all end - def project_custom_field_params - permitted_params.custom_field + def find_custom_field + @custom_field = ProjectCustomField.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 end end end diff --git a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb index bd85e8404468..e393a6a91627 100644 --- a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb @@ -35,30 +35,30 @@ module ComponentStreams included do def update_header_via_turbo_stream update_via_turbo_stream( - component: ::Settings::ProjectAttributes::HeaderComponent.new + component: ::Settings::ProjectCustomFields::HeaderComponent.new ) end - def update_section_via_turbo_stream(section:) + def update_section_via_turbo_stream(project_custom_field_section:) update_via_turbo_stream( - component: ::Settings::ProjectAttributes::SectionComponent.new( - section: + component: ::Settings::ProjectCustomFieldSections::ShowComponent.new( + project_custom_field_section: ) ) end - def update_section_dialog_body_form_via_turbo_stream(section:) + def update_section_dialog_body_form_via_turbo_stream(project_custom_field_section:) update_via_turbo_stream( component: ::Settings::ProjectAttributes::Section::DialogBodyFormComponent.new( - section: + project_custom_field_section: ) ) end - def update_sections_via_turbo_stream(sections:) + def update_sections_via_turbo_stream(project_custom_field_sections:) replace_via_turbo_stream( - component: ::Settings::ProjectAttributes::Section::IndexComponent.new( - sections: + component: ::Settings::ProjectCustomFieldSections::IndexComponent.new( + project_custom_field_sections: ) ) end diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb new file mode 100644 index 000000000000..f84db04f3665 --- /dev/null +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -0,0 +1,143 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module CustomFields + module SharedActions + extend ActiveSupport::Concern + + included do + def index_path(params = {}) + # default path below, can be overridden in the including controller + custom_fields_path(**params) + end + + def edit_path(params = {}) + # default path below, can be overridden in the including controller + edit_custom_field_path(**params) + end + + def create + call = ::CustomFields::CreateService + .new(user: current_user) + .call(get_custom_field_params.merge(type: permitted_params.custom_field_type)) + + if call.success? + flash[:notice] = t(:notice_successful_create) + call_hook(:controller_custom_fields_new_after_save, custom_field: call.result) + redirect_to index_path(tab: call.result.class.name) + else + @custom_field = call.result || new_custom_field + render action: 'new' + end + end + + def update + perform_update(get_custom_field_params) + end + + def perform_update(custom_field_params) + call = ::CustomFields::UpdateService + .new(user: current_user, model: @custom_field) + .call(custom_field_params) + + if call.success? + flash[:notice] = t(:notice_successful_update) + call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field) + redirect_back_or_default(edit_path(id: @custom_field.id)) + else + render action: 'edit' + end + end + + def reorder_alphabetical + reordered_options = @custom_field + .custom_options + .sort_by(&:value) + .each_with_index + .map do |custom_option, index| + { id: custom_option.id, position: index + 1 } + end + + perform_update(custom_options_attributes: reordered_options) + end + + def destroy + begin + @custom_field.destroy + rescue StandardError + flash[:error] = I18n.t(:error_can_not_delete_custom_field) + end + redirect_to index_path(tab: @custom_field.class.name) + end + + def delete_option + if @custom_option.destroy + num_deleted = delete_custom_values! @custom_option + + flash[:notice] = I18n.t( + :notice_custom_options_deleted, option_value: @custom_option.value, num_deleted: + ) + else + flash[:error] = @custom_option.errors.full_messages + end + + redirect_to edit_path(id: @custom_field.id) + end + + def new_custom_field + ::CustomFields::CreateService.careful_new_custom_field(permitted_params.custom_field_type) + end + + def get_custom_field_params + permitted_params.custom_field + end + + def find_custom_option + @custom_option = CustomOption.find params[:option_id] + rescue ActiveRecord::RecordNotFound + render_404 + end + + def delete_custom_values!(custom_option) + CustomValue + .where(custom_field_id: custom_option.custom_field_id, value: custom_option.id) + .delete_all + end + + def prepare_custom_option_position + return unless params[:custom_field][:custom_options_attributes] + + index = 0 + + params[:custom_field][:custom_options_attributes].each do |_id, attributes| + attributes[:position] = (index = index + 1) + end + end + end + end +end diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index 7d1e21b8330e..a9217b74da28 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -27,6 +27,7 @@ #++ class CustomFieldsController < ApplicationController + include CustomFields::SharedActions layout 'admin' before_action :require_admin @@ -53,112 +54,6 @@ def new def edit; end - def create - call = ::CustomFields::CreateService - .new(user: current_user) - .call(get_custom_field_params.merge(type: permitted_params.custom_field_type)) - - if call.success? - flash[:notice] = t(:notice_successful_create) - call_hook(:controller_custom_fields_new_after_save, custom_field: call.result) - redirect_to custom_fields_path(tab: call.result.class.name) - else - @custom_field = call.result || new_custom_field - render action: 'new' - end - end - - def update - perform_update(get_custom_field_params) - end - - def reorder_alphabetical - reordered_options = @custom_field - .custom_options - .sort_by(&:value) - .each_with_index - .map do |custom_option, index| - { id: custom_option.id, position: index + 1 } - end - - perform_update(custom_options_attributes: reordered_options) - end - - def destroy - begin - @custom_field.destroy - rescue StandardError - flash[:error] = I18n.t(:error_can_not_delete_custom_field) - end - redirect_to custom_fields_path(tab: @custom_field.class.name) - end - - def delete_option - if @custom_option.destroy - num_deleted = delete_custom_values! @custom_option - - flash[:notice] = I18n.t( - :notice_custom_options_deleted, option_value: @custom_option.value, num_deleted: - ) - else - flash[:error] = @custom_option.errors.full_messages - end - - redirect_to edit_custom_field_path(id: @custom_field.id) - end - - private - - def perform_update(custom_field_params) - call = ::CustomFields::UpdateService - .new(user: current_user, model: @custom_field) - .call(custom_field_params) - - if call.success? - flash[:notice] = t(:notice_successful_update) - call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field) - redirect_back_or_default edit_custom_field_path(id: @custom_field.id) - else - render action: 'edit' - end - end - - def new_custom_field - ::CustomFields::CreateService.careful_new_custom_field(permitted_params.custom_field_type) - end - - def get_custom_field_params - permitted_params.custom_field - end - - def find_custom_option - @custom_option = CustomOption.find params[:option_id] - rescue ActiveRecord::RecordNotFound - render_404 - end - - def delete_custom_values!(custom_option) - CustomValue - .where(custom_field_id: custom_option.custom_field_id, value: custom_option.id) - .delete_all - end - - def prepare_custom_option_position - return unless params[:custom_field][:custom_options_attributes] - - index = 0 - - params[:custom_field][:custom_options_attributes].each do |_id, attributes| - attributes[:position] = (index = index + 1) - end - end - - def find_custom_field - @custom_field = CustomField.find(params[:id]) - rescue ActiveRecord::RecordNotFound - render_404 - end - protected def default_breadcrumb @@ -172,4 +67,10 @@ def default_breadcrumb def show_local_breadcrumb true end + + def find_custom_field + @custom_field = CustomField.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end end diff --git a/app/models/project_custom_field_section_mapping.rb b/app/models/custom_field_section.rb similarity index 60% rename from app/models/project_custom_field_section_mapping.rb rename to app/models/custom_field_section.rb index 33bc324a1512..7d1cd811a4ad 100644 --- a/app/models/project_custom_field_section_mapping.rb +++ b/app/models/custom_field_section.rb @@ -26,22 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class ProjectCustomFieldSectionMapping < ApplicationRecord - belongs_to :project_custom_field_section - belongs_to :project_custom_field, class_name: 'ProjectCustomField', foreign_key: 'custom_field_id', - inverse_of: :project_custom_field_section_mappings +class CustomFieldSection < ApplicationRecord + has_many :custom_fields, dependent: :destroy - # Additionally to the database-level unique constraint, the application-level validation ensures that a - # custom_field is associated with only one section - validate :project_custom_field_uniqueness + acts_as_list scope: [:type] - acts_as_list scope: :project_custom_field_section + validates :name, presence: true - private - - def project_custom_field_uniqueness - if ProjectCustomFieldSectionMapping.where(custom_field_id:).where.not(id:).exists? - errors.add(:project_custom_field, "is already associated with another section") - end - end + default_scope { order(:position) } end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 3b8a5f23aa60..f90b7f408143 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -466,7 +466,7 @@ def self.permitted_attributes :possible_values, :multi_value, :content_right_to_left, - :project_custom_field_section_id, + :custom_field_section_id, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } ], diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index bd27c10091e7..69560870d83a 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -27,20 +27,11 @@ #++ class ProjectCustomField < CustomField - # don't pollute the custom_fields table with a section_id column which is only used by ProjectCustomFields - # use a separate mapping table instead - has_many :project_custom_field_section_mappings, - dependent: :destroy, - inverse_of: :project_custom_field, - class_name: 'ProjectCustomFieldSectionMapping', - foreign_key: 'custom_field_id' + belongs_to :project_custom_field_section, class_name: 'ProjectCustomFieldSection', foreign_key: :custom_field_section_id + has_many :project_custom_field_project_mappings, class_name: 'ProjectCustomFieldProjectMapping', foreign_key: :custom_field_id, + dependent: :destroy, inverse_of: :project_custom_field - has_many :project_custom_field_sections, - through: :project_custom_field_section_mappings - - accepts_nested_attributes_for :project_custom_field_sections - - validate :exactly_one_section_mapped + acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] def type_name :label_project_plural @@ -53,34 +44,4 @@ def self.visible(user = User.current) where(visible: true) end end - - def exactly_one_section_mapped - return unless persisted? - - unless project_custom_field_sections.count == 1 - errors.add(:base, "Exactly one section must be mapped to this custom field.") - end - end - - def project_custom_field_section_mapping - project_custom_field_section_mappings.first - end - - def project_custom_field_section - project_custom_field_sections.first - end - - def project_custom_field_section_id - project_custom_field_section&.id - end - - def project_custom_field_section=(section) - # without this reload, a nil assignment is not recovered properly - project_custom_field_sections.reload - - ActiveRecord::Base.transaction do - project_custom_field_sections.clear - project_custom_field_sections << section - end - end end diff --git a/app/models/project_custom_field_project_mapping.rb b/app/models/project_custom_field_project_mapping.rb index e6d5c413875b..faff27f27c63 100644 --- a/app/models/project_custom_field_project_mapping.rb +++ b/app/models/project_custom_field_project_mapping.rb @@ -29,7 +29,7 @@ class ProjectCustomFieldProjectMapping < ApplicationRecord belongs_to :project belongs_to :project_custom_field, class_name: 'ProjectCustomField', foreign_key: 'custom_field_id', - inverse_of: :project_custom_field_section_mappings + inverse_of: :project_custom_field_project_mappings # # Additionally to the database-level unique constraint, the application-level validation ensures that a # # custom_field is associated with only one section diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index 5df3ab10531c..fcfe4b98ae50 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -26,17 +26,5 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class ProjectCustomFieldSection < ApplicationRecord - has_many :project_custom_field_section_mappings, dependent: :destroy - has_many :project_custom_fields, through: :project_custom_field_section_mappings - - acts_as_list - - validates :name, presence: true - - default_scope { order(:position) } - - def project_custom_fields_ordered_by_postion_in_section - project_custom_fields.reorder('project_custom_field_section_mappings.position ASC') - end +class ProjectCustomFieldSection < CustomFieldSection end diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb index 852e11dcb026..b3f3bc19b06e 100644 --- a/app/views/admin/settings/project_custom_fields/edit.html.erb +++ b/app/views/admin/settings/project_custom_fields/edit.html.erb @@ -26,7 +26,6 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> - - <%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new(project_custom_field: @project_custom_field)) %> - <%= render(Settings::ProjectCustomFields::FormComponent.new(project_custom_field: @project_custom_field)) %> - +<%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field)) %> +<%= render(Settings::ProjectCustomFields::FormComponent.new(custom_field: @custom_field)) %> + diff --git a/app/views/admin/settings/project_custom_fields/index.html.erb b/app/views/admin/settings/project_custom_fields/index.html.erb index dd9ec6c04150..14b91d79930d 100644 --- a/app/views/admin/settings/project_custom_fields/index.html.erb +++ b/app/views/admin/settings/project_custom_fields/index.html.erb @@ -27,8 +27,8 @@ See COPYRIGHT and LICENSE files for more details. ++#%>
- - <%= render(Settings::ProjectAttributes::HeaderComponent.new()) %> - <%= render(Settings::ProjectAttributes::Section::IndexComponent.new(sections: @sections)) %> - + <%= render(Settings::ProjectCustomFields::HeaderComponent.new()) %> + <%= render(Settings::ProjectCustomFieldSections::IndexComponent.new( + project_custom_field_sections: @project_custom_field_sections + )) %>
diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb index cee5c4fc715b..bc254c2f1cd2 100644 --- a/app/views/admin/settings/project_custom_fields/new.html.erb +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -26,7 +26,5 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> - - <%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new()) %> - <%= render(Settings::ProjectCustomFields::FormComponent.new(project_custom_field: @project_custom_field)) %> - +<%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new()) %> +<%= render(Settings::ProjectCustomFields::FormComponent.new(custom_field: @custom_field)) %> diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index 53644c6fd6e7..a9504bb2edb8 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -26,10 +26,22 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> + + +<% @custom_field = custom_field if @custom_field.nil? %> +
<%= f.text_field :name, required: true, container_class: '-middle' %>
+ <% if @custom_field.type == 'ProjectCustomField' %> +
+ <%= f.select :custom_field_section_id, + ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, + { container_class: '-slim' } + %> +
+ <% end %>
<%= f.select :field_format, custom_field_formats_for_select(@custom_field), diff --git a/config/routes.rb b/config/routes.rb index a03141d29346..1f57c092e58a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -418,9 +418,10 @@ resource :api, controller: '/admin/settings/api_settings', only: %i[show update] resource :work_packages, controller: '/admin/settings/work_packages_settings', only: %i[show update] resource :projects, controller: '/admin/settings/projects_settings', only: %i[show update] - resources :project_custom_fields, controller: '/admin/settings/project_custom_fields', - only: %i[index show new create edit update] do + resources :project_custom_fields, controller: '/admin/settings/project_custom_fields' do member do + delete "options/:option_id", action: "delete_option", as: :delete_option_of + post :reorder_alphabetical put :move put :drop end diff --git a/db/migrate/20231030103212_create_project_custom_field_sections.rb b/db/migrate/20231030103212_create_project_custom_field_sections.rb deleted file mode 100644 index f79300b9d675..000000000000 --- a/db/migrate/20231030103212_create_project_custom_field_sections.rb +++ /dev/null @@ -1,47 +0,0 @@ -class CreateProjectCustomFieldSections < ActiveRecord::Migration[7.0] - def up - create_table :project_custom_field_sections do |t| - t.integer :position - t.string :name - - t.timestamps - end - - # don't pollute the custom_fields table with a section_id column which is only used by ProjectCustomFields - # use a separate mapping table instead - create_table :project_custom_field_section_mappings do |t| - t.references :project_custom_field_section, foreign_key: true, index: { - name: 'index_project_cfs_mappings_on_section_id' - } - t.references :custom_field, foreign_key: true - t.integer :position - - t.timestamps - end - - # Add a unique constraint to ensure that a custom_field can only be added to one section - add_index :project_custom_field_section_mappings, :custom_field_id, unique: true, - name: 'index_project_cfs_mappings_on_custom_field_id' - - create_and_assign_default_section - end - - def down - drop_table :project_custom_field_section_mappings - drop_table :project_custom_field_sections - end - - private - - def create_and_assign_default_section - section = ProjectCustomFieldSection.create!( - name: "Project attributes" - ) - - mappings = ProjectCustomField.pluck(:id).map do |id| - { project_custom_field_section_id: section.id, custom_field_id: id } - end - - ProjectCustomFieldSectionMapping.create!(mappings) - end -end diff --git a/db/migrate/20231123111357_create_custom_field_sections.rb b/db/migrate/20231123111357_create_custom_field_sections.rb new file mode 100644 index 000000000000..5d2d259cc1e1 --- /dev/null +++ b/db/migrate/20231123111357_create_custom_field_sections.rb @@ -0,0 +1,36 @@ +class CreateCustomFieldSections < ActiveRecord::Migration[7.0] + def up + create_table :custom_field_sections do |t| + t.integer :position + t.string :name + t.string :type # project or nil (-> work_package) + + t.timestamps + end + + add_reference :custom_fields, :custom_field_section + add_column :custom_fields, :position_in_custom_field_section, :integer, null: true + + create_and_assign_default_section + end + + def down + remove_reference :custom_fields, :custom_field_section + remove_column :custom_fields, :position_in_custom_field_section + drop_table :custom_field_sections + end + + private + + def create_and_assign_default_section + # for project custom fields only + section = ProjectCustomFieldSection.create!( + name: "Project attributes" + ) + + # trigger acts_as_list callbacks via updating each record instead of bulk update + ProjectCustomField.find_each do |project_custom_field| + project_custom_field.update!(custom_field_section_id: section.id) + end + end +end diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts index 2c5a68743709..315ecc6e9eb2 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts @@ -32,7 +32,7 @@ import * as Turbo from '@hotwired/turbo'; import { Controller } from '@hotwired/stimulus'; import { Drake } from 'dragula'; import { debugLog } from 'core-app/shared/helpers/debug_output'; -import { dropRight } from 'lodash'; +import { dropRight, get } from 'lodash'; export default class extends Controller { drake:Drake|undefined; @@ -129,7 +129,13 @@ export default class extends Controller { async drop(el:Element, target:Element, _source:Element|null, sibling:Element|null) { const dropUrl = el.getAttribute('data-drop-url'); - const targetPosition = Array.from(target.children).indexOf(el); + let targetPosition = Array.from(target.children).indexOf(el); + if(target.children.length > 0 && target.children[0].getAttribute('data-empty-list-item') == 'true'){ + // if the target container is empty, a list item showing an empty message might be shown + // this should not be counted as a list item + // thus we need to subtract 1 from the target position + targetPosition--; + } const targetConfig: any = this.targetConfigs.find((config: any) => config.container == target); const targetId = targetConfig?.targetId as string | undefined; From 7d56b2a4a112224e0698ac478b25c5ab831997a1 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 27 Nov 2023 10:12:28 +0100 Subject: [PATCH 007/218] fixed active menu item --- .../admin/settings/project_custom_fields_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index ded7af72c9da..430712d7a35b 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -32,7 +32,7 @@ class ProjectCustomFieldsController < ::Admin::SettingsController include OpTurbo::ComponentStream include Admin::Settings::ProjectCustomFields::ComponentStreams - menu_item :project_custom_field_settings + menu_item :project_custom_fields_settings before_action :set_sections, only: %i[show index edit update move drop] before_action :find_custom_field, only: %i(show edit update destroy delete_option reorder_alphabetical move drop) From f7f3b2b16b108367a6563ef379e3b115c6793347 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 27 Nov 2023 10:28:45 +0100 Subject: [PATCH 008/218] disable project custom field management through custom fields UI --- app/controllers/custom_fields_controller.rb | 24 +++++++++++++++------ app/helpers/custom_fields_helper.rb | 13 +++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index a9217b74da28..1c00b16886e2 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -27,7 +27,7 @@ #++ class CustomFieldsController < ApplicationController - include CustomFields::SharedActions + include CustomFields::SharedActions # share logic with ProjectCustomFieldsControlller layout 'admin' before_action :require_admin @@ -37,7 +37,10 @@ class CustomFieldsController < ApplicationController def index # loading wp cfs exclicity to allow for eager loading - @custom_fields_by_type = CustomField.all.where.not(type: 'WorkPackageCustomField').group_by { |f| f.class.name } + @custom_fields_by_type = CustomField.all + .where.not(type: 'WorkPackageCustomField') + .where.not(type: 'ProjectCustomField') # ProjecCustomFields now managed in a different UI + .group_by { |f| f.class.name } @custom_fields_by_type['WorkPackageCustomField'] = WorkPackageCustomField.includes(:types).all @tab = params[:tab] || 'WorkPackageCustomField' @@ -46,13 +49,12 @@ def index def new @custom_field = new_custom_field - if @custom_field.nil? - flash[:error] = 'Invalid CF type' - redirect_to action: :index - end + check_custom_field end - def edit; end + def edit + check_custom_field + end protected @@ -73,4 +75,12 @@ def find_custom_field rescue ActiveRecord::RecordNotFound render_404 end + + def check_custom_field + if @custom_field.nil? || @custom_field.type == 'ProjectCustomField' + # ProjecCustomFields now managed in a different UI + flash[:error] = 'Invalid CF type' + redirect_to action: :index + end + end end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 09db112430e8..8007f98c026b 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -41,12 +41,13 @@ def custom_fields_tabs path: custom_fields_path(tab: :TimeEntryCustomField), label: :label_spent_time }, - { - name: 'ProjectCustomField', - partial: 'custom_fields/tab', - path: custom_fields_path(tab: :ProjectCustomField), - label: :label_project_plural - }, + # ProjecCustomFields now managed in a different UI + # { + # name: 'ProjectCustomField', + # partial: 'custom_fields/tab', + # path: custom_fields_path(tab: :ProjectCustomField), + # label: :label_project_plural + # }, { name: 'VersionCustomField', partial: 'custom_fields/tab', From 2901b47f0461c21e30c59728f183479d96a290f1 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 27 Nov 2023 15:53:46 +0100 Subject: [PATCH 009/218] refactored project attributes sidebar and dialog following previous refactorings --- app/models/project_custom_field.rb | 8 +++ config/locales/en.yml | 1 + .../show_component.html.erb | 12 ---- .../sidebar_component.html.erb | 16 ----- .../sections}/edit_dialog_component.html.erb | 4 +- .../sections}/edit_dialog_component.rb | 30 ++++++--- .../show_component.html.erb | 16 +++++ .../project_custom_fields}/show_component.rb | 21 +++--- .../sections}/show_component.html.erb | 11 ++-- .../sections}/show_component.rb | 32 +++++---- .../sidebar_component.html.erb | 17 +++++ .../sidebar_component.rb | 12 +++- .../overviews/overviews_controller.rb | 65 ++++++++++++------- 13 files changed, 156 insertions(+), 89 deletions(-) delete mode 100644 modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb delete mode 100644 modules/overviews/app/components/project_attributes/sidebar_component.html.erb rename modules/overviews/app/components/{project_attributes/section => project_custom_fields/sections}/edit_dialog_component.html.erb (84%) rename modules/overviews/app/components/{project_attributes/section => project_custom_fields/sections}/edit_dialog_component.rb (81%) create mode 100644 modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb rename modules/overviews/app/components/{project_attributes/section/custom_field_value => project_custom_fields/sections/project_custom_fields}/show_component.rb (74%) rename modules/overviews/app/components/{project_attributes/section => project_custom_fields/sections}/show_component.html.erb (66%) rename modules/overviews/app/components/{project_attributes/section => project_custom_fields/sections}/show_component.rb (63%) create mode 100644 modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb rename modules/overviews/app/components/{project_attributes => project_custom_fields}/sidebar_component.rb (73%) diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 69560870d83a..c11c4e00cf0d 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -33,6 +33,8 @@ class ProjectCustomField < CustomField acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] + after_create :activate_in_all_projects + def type_name :label_project_plural end @@ -44,4 +46,10 @@ def self.visible(user = User.current) where(visible: true) end end + + def activate_in_all_projects + # until we have the project mapping UI, activate the custom field in all projects + mappings = Project.active.map { |project| { project_id: project.id, custom_field_id: id } } + ProjectCustomFieldProjectMapping.create!(mappings) + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 86d30f31b5a8..15b593c5579c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1972,6 +1972,7 @@ en: label_no_data: "No data to display" label_no_parent_page: "No parent page" label_nothing_display: "Nothing to display" + label_not_set_yet: "Not set yet" label_nobody: "nobody" label_not_found: 'not found' label_none: "none" diff --git a/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb b/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb deleted file mode 100644 index 7b8062a52b62..000000000000 --- a/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -<%= - flex_layout(align_items: :flex_start, justify_content: :space_between) do |custom_field_value_container| - # temporarily using inline styles in order to align the content as desired - custom_field_value_container.with_column(mr: 2, style: "width: 130px;") do - render(Primer::Beta::Text.new(font_weight: :bold)) { @custom_field_value.custom_field.name } - end - - custom_field_value_container.with_column(flex: 1) do - render(Primer::Beta::Text.new()) { formated_value } - end - end -%> diff --git a/modules/overviews/app/components/project_attributes/sidebar_component.html.erb b/modules/overviews/app/components/project_attributes/sidebar_component.html.erb deleted file mode 100644 index 85a01ffe93e2..000000000000 --- a/modules/overviews/app/components/project_attributes/sidebar_component.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%= - content_tag("turbo-frame", id: "project-attributes-sidebar") do - component_wrapper do - flex_layout do |sections_container| - ProjectCustomFieldSection.all.order(created_at: :asc).each do |project_custom_field_section| - sections_container.with_row(mb: 3) do - render(ProjectAttributes::Section::ShowComponent.new( - project: @project, - project_custom_field_section: project_custom_field_section - )) - end - end - end - end - end -%> diff --git a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb similarity index 84% rename from modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb rename to modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index 239a5d082052..93ab06ee19cb 100644 --- a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -10,9 +10,9 @@ component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 500px;")) do flex_layout(my: 3) do |flex| - project_custom_field_values_of_section.group_by { |cfv| cfv.custom_field_id }.sort.each do |custom_field_id, custom_field_values| + @active_project_custom_fields_of_section.each do |project_custom_field| flex.with_row(mb: 2) do - render_custom_field_value_input(f, custom_field_id, custom_field_values) + render_custom_field_value_input(f, project_custom_field, project_custom_field_values_for(project_custom_field.id)) end end end diff --git a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb similarity index 81% rename from modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb rename to modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb index d608059e1e24..d914a9975eb7 100644 --- a/modules/overviews/app/components/project_attributes/section/edit_dialog_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb @@ -26,32 +26,42 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectAttributes - module Section +module ProjectCustomFields + module Sections class EditDialogComponent < ApplicationComponent include ApplicationHelper include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(project:, project_custom_field_section:, custom_field_values:) + def initialize(project:, + project_custom_field_section:, + active_project_custom_fields_of_section:, + project_custom_field_values:) super @project = project @project_custom_field_section = project_custom_field_section - @custom_field_values = custom_field_values + @active_project_custom_fields_of_section = active_project_custom_fields_of_section + @project_custom_field_values = project_custom_field_values end private - def project_custom_field_values_of_section - @custom_field_values.to_a.select do |cfv| - @project_custom_field_section.project_custom_fields.pluck(:id).include?(cfv.custom_field_id) + def project_custom_field_values_for(project_custom_field_id) + values = @project_custom_field_values.select { |pcfv| pcfv.custom_field_id == project_custom_field_id } + + if values.empty? + [CustomValue.new( + custom_field_id: project_custom_field_id, + customized_id: @project.id, + customized_type: "Project" + )] + else + values end end - def render_custom_field_value_input(form, custom_field_id, custom_field_values) - custom_field = CustomField.find(custom_field_id) - + def render_custom_field_value_input(form, custom_field, custom_field_values) if custom_field.multi_value? render_multi_value_custom_field_input(form, custom_field, custom_field_values) else diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb new file mode 100644 index 000000000000..ae66097c7c2f --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb @@ -0,0 +1,16 @@ +<%= + flex_layout(align_items: :flex_start, justify_content: :space_between) do |custom_field_value_container| + # temporarily using inline styles in order to align the content as desired + custom_field_value_container.with_row(mb: 1) do + render(Primer::Beta::Text.new(font_weight: :bold)) { @project_custom_field.name } + end + + custom_field_value_container.with_row do + if @project_custom_field_value.blank? + render(Primer::Beta::Text.new(color: :subtle)) { t('label_not_set_yet') } + else + render(Primer::Beta::Text.new()) { formated_value } + end + end + end +%> diff --git a/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb similarity index 74% rename from modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb rename to modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb index a9f86db6c934..602bb6f18203 100644 --- a/modules/overviews/app/components/project_attributes/section/custom_field_value/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb @@ -26,29 +26,32 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectAttributes - module Section - module CustomFieldValue +module ProjectCustomFields + module Sections + module ProjectCustomFields class ShowComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - def initialize(custom_field_value:) + def initialize(project_custom_field:, project_custom_field_value:) super - @custom_field_value = custom_field_value + @project_custom_field = project_custom_field + @project_custom_field_value = project_custom_field_value end private def formated_value - case @custom_field_value.custom_field.field_format + return if @project_custom_field_value.blank? + + case @project_custom_field.field_format when "text" - ::OpenProject::TextFormatting::Renderer.format_text(@custom_field_value.typed_value) + ::OpenProject::TextFormatting::Renderer.format_text(@project_custom_field_value.typed_value) when "date" - format_date(@custom_field_value.typed_value) + format_date(@project_custom_field_value.typed_value) else - @custom_field_value.typed_value&.to_s + @project_custom_field_value.typed_value&.to_s end end end diff --git a/modules/overviews/app/components/project_attributes/section/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb similarity index 66% rename from modules/overviews/app/components/project_attributes/section/show_component.html.erb rename to modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 5946816e993e..6a7f26a3d00a 100644 --- a/modules/overviews/app/components/project_attributes/section/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -12,7 +12,7 @@ id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}", src: project_attribute_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), size: :medium_portrait, - title: "Edit #{ @project_custom_field_section.name }", + title: "#{t('label_edit')} #{ @project_custom_field_section.name }", button_icon: :pencil, button_attributes: { scheme: :invisible } )) @@ -20,9 +20,12 @@ end end - project_custom_field_values_of_section.each do |custom_field_value| - details_container.with_row(mb: 1) do - render(ProjectAttributes::Section::CustomFieldValue::ShowComponent.new(custom_field_value: custom_field_value)) + sorted_project_custom_fields.each do |project_custom_field| + details_container.with_row(mb: 3) do + render(ProjectCustomFields::Sections::ProjectCustomFields::ShowComponent.new( + project_custom_field: project_custom_field, + project_custom_field_value: get_eager_loaded_project_custom_field_value_for(project_custom_field.id) + )) end end end diff --git a/modules/overviews/app/components/project_attributes/section/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb similarity index 63% rename from modules/overviews/app/components/project_attributes/section/show_component.rb rename to modules/overviews/app/components/project_custom_fields/sections/show_component.rb index aea681c01589..d74633508007 100644 --- a/modules/overviews/app/components/project_attributes/section/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb @@ -26,34 +26,42 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectAttributes - module Section +module ProjectCustomFields + module Sections class ShowComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:, project_custom_field_section:) + def initialize(project:, project_custom_field_section:, project_custom_fields:) super @project = project @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_fields + + eager_load_project_custom_field_values end private - def project_custom_field_values - active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping - .where(project_id: @project.id) - .pluck(:custom_field_id) + def eager_load_project_custom_field_values + # TODO: move to service + @eager_loaded_project_custom_field_values = CustomValue + .includes(custom_field: :custom_options) + .where( + custom_field_id: @project_custom_fields.pluck(:id), + customized_id: @project.id + ) + .to_a + end - CustomValue.where(custom_field_id: active_custom_field_ids_of_project, customized_id: @project.id) + def sorted_project_custom_fields + @project_custom_fields.sort_by { |pcf| pcf.position_in_custom_field_section } end - def project_custom_field_values_of_section - project_custom_field_values.select do |cfv| - @project_custom_field_section.project_custom_fields.pluck(:id).include?(cfv.custom_field_id) - end + def get_eager_loaded_project_custom_field_value_for(custom_field_id) + @eager_loaded_project_custom_field_values.find { |pcfv| pcfv.custom_field_id == custom_field_id } end end end diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb new file mode 100644 index 000000000000..43669a2f995a --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -0,0 +1,17 @@ +<%= + content_tag("turbo-frame", id: "project-attributes-sidebar") do + component_wrapper do + flex_layout do |sections_container| + @active_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| + sections_container.with_row(mb: 3) do + render(ProjectCustomFields::Sections::ShowComponent.new( + project: @project, + project_custom_field_section: get_eager_loaded_project_custom_field_section(project_custom_field_section_id), + project_custom_fields: project_custom_fields + )) + end + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_attributes/sidebar_component.rb b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb similarity index 73% rename from modules/overviews/app/components/project_attributes/sidebar_component.rb rename to modules/overviews/app/components/project_custom_fields/sidebar_component.rb index 7ae51c2cbc0d..207f065c038e 100644 --- a/modules/overviews/app/components/project_attributes/sidebar_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb @@ -26,16 +26,24 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectAttributes +module ProjectCustomFields class SidebarComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:) + def initialize(project:, project_custom_field_sections:, active_project_custom_fields_grouped_by_section:) super @project = project + @project_custom_field_sections = project_custom_field_sections + @active_project_custom_fields_grouped_by_section = active_project_custom_fields_grouped_by_section + end + + private + + def get_eager_loaded_project_custom_field_section(project_custom_field_section_id) + @project_custom_field_sections.find { |pcfs| pcfs.id == project_custom_field_section_id } end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index fdceb3ffa1aa..e09105c6d52d 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -8,8 +8,10 @@ class OverviewsController < ::Grids::BaseInProjectController def attributes_sidebar render( - ProjectAttributes::SidebarComponent.new( - project: @project + ProjectCustomFields::SidebarComponent.new( + project: @project, + project_custom_field_sections: ProjectCustomFieldSection.all, + active_project_custom_fields_grouped_by_section: ), layout: false ) @@ -18,39 +20,46 @@ def attributes_sidebar def attribute_section_dialog section = ProjectCustomFieldSection.find(params[:section_id]) - active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping - .where(project_id: @project.id) - .pluck(:custom_field_id) + active_project_custom_fields_of_section = active_project_custom_fields_grouped_by_section[section.id] + .sort_by(&:position_in_custom_field_section) - custom_field_values = CustomValue.where(custom_field_id: active_custom_field_ids_of_project, customized_id: @project.id) + eager_loaded_project_custom_field_values = CustomValue.where( + custom_field_id: active_project_custom_fields_of_section.pluck(:id), + customized_id: @project.id + ).to_a render( - ProjectAttributes::Section::EditDialogComponent.new( + ProjectCustomFields::Sections::EditDialogComponent.new( project: @project, project_custom_field_section: section, - custom_field_values: + active_project_custom_fields_of_section:, + project_custom_field_values: eager_loaded_project_custom_field_values ), layout: false ) end def update_attributes + # prototypical implementation # manual nested attributes update as the project model is not yet natively supporting it # needs refactoring section = ProjectCustomFieldSection.find(params[:section_id]) + active_project_custom_fields_of_section = active_project_custom_fields_grouped_by_section[section.id] + .sort_by(&:position_in_custom_field_section) + modified_custom_field_values = [] + has_errors = false + ActiveRecord::Base.transaction do # transaction to rollback if any of the custom field values fails to save project_attribute_params[:custom_field_values_attributes]&.each do |custom_value_id, attributes| custom_value = CustomValue.find(custom_value_id.to_i) custom_value.value = attributes[:value] - unless custom_value.save - has_errors = true - end + has_errors = true if custom_value.invalid? modified_custom_field_values << custom_value end @@ -62,9 +71,7 @@ def update_attributes customized_id: @project.id ) - unless custom_value.save - has_errors = true - end + has_errors = true if custom_value.invalid? modified_custom_field_values << custom_value end @@ -85,26 +92,27 @@ def update_attributes customized_id: @project.id ) - unless custom_value.save - has_errors = true - end + has_errors = true if custom_value.invalid? modified_custom_field_values << custom_value end end if has_errors update_via_turbo_stream( - component: ProjectAttributes::Section::EditDialogComponent.new( + component: ProjectCustomFields::Sections::EditDialogComponent.new( project: @project, project_custom_field_section: section, - custom_field_values: modified_custom_field_values + active_project_custom_fields_of_section:, + project_custom_field_values: modified_custom_field_values ) ) - raise ActiveRecord::Rollback else + modified_custom_field_values.each(&:save!) update_via_turbo_stream( - component: ProjectAttributes::SidebarComponent.new( - project: @project + component: ProjectCustomFields::SidebarComponent.new( + project: @project, + project_custom_field_sections: ProjectCustomFieldSection.all, + active_project_custom_fields_grouped_by_section: ) ) end @@ -129,5 +137,18 @@ def project_attribute_params multi_custom_field_values_attributes: [:custom_field_id, { values: [] }] ) end + + def active_project_custom_fields_grouped_by_section + # TODO: move to service + active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping + .where(project_id: @project.id) + .pluck(:custom_field_id) + + ProjectCustomField + .includes(:project_custom_field_section) + .where(id: active_custom_field_ids_of_project) + .sort_by { |pcf| pcf.project_custom_field_section.position } + .group_by(&:custom_field_section_id) + end end end From 396f9e2fd7cf708d1b6a6df14fecf9e59bc35830 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 28 Nov 2023 13:33:04 +0100 Subject: [PATCH 010/218] introduce project custom field mapping UI --- .../custom_field_row_component.html.erb | 32 ++++++++ .../custom_field_row_component.rb | 55 +++++++++++++ .../index_component.html.erb | 16 ++++ .../index_component.rb | 59 ++++++++++++++ .../show_component.html.erb | 46 +++++++++++ .../show_component.rb | 63 +++++++++++++++ .../header_component.html.erb | 15 ++++ .../project_custom_fields/header_component.rb | 44 +++++++++++ .../component_streams.rb | 55 +++++++++++++ .../project_custom_fields_controller.rb | 78 +++++++++++++++++++ .../project_custom_fields/show.html.erb | 38 +++++++++ config/initializers/menus.rb | 1 + config/initializers/permissions.rb | 7 ++ config/locales/en.yml | 5 ++ config/routes.rb | 1 + 15 files changed, 515 insertions(+) create mode 100644 app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb create mode 100644 app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb create mode 100644 app/components/projects/settings/project_custom_field_sections/index_component.html.erb create mode 100644 app/components/projects/settings/project_custom_field_sections/index_component.rb create mode 100644 app/components/projects/settings/project_custom_field_sections/show_component.html.erb create mode 100644 app/components/projects/settings/project_custom_field_sections/show_component.rb create mode 100644 app/components/projects/settings/project_custom_fields/header_component.html.erb create mode 100644 app/components/projects/settings/project_custom_fields/header_component.rb create mode 100644 app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb create mode 100644 app/controllers/projects/settings/project_custom_fields_controller.rb create mode 100644 app/views/projects/settings/project_custom_fields/show.html.erb diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb new file mode 100644 index 000000000000..dbe6078f60d3 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -0,0 +1,32 @@ +<%= + component_wrapper do + flex_layout(align_items: :center) do |custom_field_container| + custom_field_container.with_column(mr: 2) do + if active_in_project? + render(Primer::Beta::IconButton.new( + tag: :a, + href: project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), + scheme: :invisible, + icon: 'check-circle', + 'aria-label': 'Active in this project, click to disable', + data: { 'turbo-method': :put, 'turbo-stream': true } + )) + else + render(Primer::Beta::IconButton.new( + tag: :a, + href: project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), + scheme: :invisible, + icon: 'circle', + 'aria-label': 'Inactive in this project, click to enable', + data: { 'turbo-method': :put, 'turbo-stream': true } + )) + end + end + custom_field_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + @project_custom_field.name + end + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..e2ac8ff65d4c --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,55 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Projects + module Settings + module ProjectCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:, project_custom_field:, project_custom_field_project_mappings:) + super + + @project = project + @project_custom_field = project_custom_field + @project_custom_field_project_mappings = project_custom_field_project_mappings + end + + private + + def active_in_project? + @project_custom_field_project_mappings.any? do |mapping| + mapping.custom_field_id == @project_custom_field.id + end + end + end + end + end +end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb new file mode 100644 index 000000000000..0cf52308aad9 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -0,0 +1,16 @@ +<%= + component_wrapper do + flex_layout do |flex| + @project_custom_fields_grouped_by_section.each do |section_id, project_custom_fields| + flex.with_row do + render(Projects::Settings::ProjectCustomFieldSections::ShowComponent.new( + project: @project, + project_custom_field_section: get_eager_loaded_project_custom_field_section(section_id), + project_custom_fields:, + project_custom_field_project_mappings: @project_custom_field_project_mappings + )) + end + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb new file mode 100644 index 000000000000..bba76f062bdb --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -0,0 +1,59 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Projects + module Settings + module ProjectCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize( + project:, + project_custom_field_sections:, + project_custom_fields_grouped_by_section:, + project_custom_field_project_mappings: + ) + super + + @project = project + @project_custom_field_sections = project_custom_field_sections + @project_custom_fields_grouped_by_section = project_custom_fields_grouped_by_section + @project_custom_field_project_mappings = project_custom_field_project_mappings + end + + private + + def get_eager_loaded_project_custom_field_section(section_id) + @project_custom_field_sections.find { |section| section.id == section_id } + end + end + end + end +end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb new file mode 100644 index 000000000000..75485fdaf7fd --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -0,0 +1,46 @@ +<%= + component_wrapper do + render(Primer::Beta::BorderBox.new(mt: 3)) do |component| + component.with_header(font_weight: :bold) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + @project_custom_field_section.name + end + end + end + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column do + # render(Primer::Alpha::ActionMenu.new) do |menu| + # menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_section_actions"), scheme: :invisible) + # edit_action_item(menu) + # move_actions(menu) + # if @project_custom_fields.empty? + # delete_action_item(menu) + # else + # disabled_delete_action_item(menu) + # end + # end + end + end + end + end + if @project_custom_fields.empty? + component.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } + end + else + sorted_project_custom_fields.each do |project_custom_field| + component.with_row do + render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( + project: @project, + project_custom_field:, + project_custom_field_project_mappings: @project_custom_field_project_mappings, + )) + end + end + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb new file mode 100644 index 000000000000..beb8c9dee73e --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -0,0 +1,63 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Projects + module Settings + module ProjectCustomFieldSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize( + project:, + project_custom_field_section:, + project_custom_fields:, + project_custom_field_project_mappings: + ) + super + + @project = project + @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_fields + @project_custom_field_project_mappings = project_custom_field_project_mappings + end + + private + + def wrapper_uniq_by + @project_custom_field_section.id + end + + def sorted_project_custom_fields + @project_custom_fields.sort_by { |pcf| pcf.position_in_custom_field_section } + end + end + end + end +end diff --git a/app/components/projects/settings/project_custom_fields/header_component.html.erb b/app/components/projects/settings/project_custom_fields/header_component.html.erb new file mode 100644 index 000000000000..d7e5a689eac9 --- /dev/null +++ b/app/components/projects/settings/project_custom_fields/header_component.html.erb @@ -0,0 +1,15 @@ +<%= + flex_layout do |header_container| + header_container.with_row do + render(Primer::Beta::Heading.new(tag: :h1)) { t('projects.settings.project_custom_fields.header.title') } + end + header_container.with_row(mt: 1, pb: 3, mb: 2, border: :bottom) do + render(Primer::Beta::Text.new(color: :muted)) do + t('projects.settings.project_custom_fields.header.description', + overview_url: project_path(@project), + admin_settings_url: admin_settings_project_custom_fields_path + ).html_safe + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_fields/header_component.rb b/app/components/projects/settings/project_custom_fields/header_component.rb new file mode 100644 index 000000000000..c3881b428e8e --- /dev/null +++ b/app/components/projects/settings/project_custom_fields/header_component.rb @@ -0,0 +1,44 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Projects + module Settings + module ProjectCustomFields + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize(project:) + super + + @project = project + end + end + end + end +end diff --git a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb new file mode 100644 index 000000000000..1e1f1cd5f2d2 --- /dev/null +++ b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb @@ -0,0 +1,55 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Projects + module Settings + module ProjectCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_sections_via_turbo_stream( + project: @project, + project_custom_field_sections: @project_custom_field_sections, + project_custom_fields_grouped_by_section: @project_custom_fields_grouped_by_section, + project_custom_field_project_mappings: @project_custom_field_project_mappings + ) + update_via_turbo_stream( + component: ::Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( + project:, + project_custom_field_sections:, + project_custom_fields_grouped_by_section:, + project_custom_field_project_mappings: + ) + ) + end + end + end + end + end +end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb new file mode 100644 index 000000000000..77fe7cdfe1ab --- /dev/null +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -0,0 +1,78 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsController + include OpTurbo::ComponentStream + include Projects::Settings::ProjectCustomFields::ComponentStreams + + menu_item :settings_project_custom_fields + + before_action :eager_load_project_custom_field_sections, only: %i[show update] + before_action :eager_load_project_custom_fields, only: %i[show update] + before_action :eager_load_project_custom_field_project_mappings, only: %i[show update] + + def show; end + + def update + mapping = ProjectCustomFieldProjectMapping.find_or_initialize_by( + project_id: @project.id, + custom_field_id: params[:project_custom_field_id] + ) + + if mapping.persisted? + mapping.destroy! + else + mapping.save! + end + + eager_load_project_custom_field_project_mappings # reload mappings + + update_sections_via_turbo_stream + + respond_with_turbo_streams + end + + private + + def eager_load_project_custom_field_sections + @project_custom_field_sections = ProjectCustomFieldSection.all.to_a + end + + def eager_load_project_custom_fields + @project_custom_fields_grouped_by_section = ProjectCustomField + .includes(:project_custom_field_section) + .sort_by { |pcf| pcf.project_custom_field_section.position } + .group_by(&:custom_field_section_id) + end + + def eager_load_project_custom_field_project_mappings + @project_custom_field_project_mappings = ProjectCustomFieldProjectMapping + .where(project_id: @project.id) + .to_a + end +end diff --git a/app/views/projects/settings/project_custom_fields/show.html.erb b/app/views/projects/settings/project_custom_fields/show.html.erb new file mode 100644 index 000000000000..9b62dc8d9f5d --- /dev/null +++ b/app/views/projects/settings/project_custom_fields/show.html.erb @@ -0,0 +1,38 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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. + +++#%> +
+ <%= render(Projects::Settings::ProjectCustomFields::HeaderComponent.new(project: @project)) %> + <%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( + project: @project, + project_custom_field_sections: @project_custom_field_sections, + project_custom_fields_grouped_by_section: @project_custom_fields_grouped_by_section, + project_custom_field_project_mappings: @project_custom_field_project_mappings, + )) %> +
+ diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 4cb63a198812..7e38a9eb562d 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -613,6 +613,7 @@ { general: :label_information_plural, + project_custom_fields: :label_project_attributes_plural, modules: :label_module_plural, types: :label_work_package_types, custom_fields: :label_custom_field_plural, diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 51da2b030c7d..680efcaf09fc 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -109,6 +109,13 @@ permissible_on: :project, require: :member + map.permission :select_project_custom_fields, + { + 'projects/settings/project_custom_fields': %i[show update] + }, + permissible_on: :project, + require: :member + map.permission :manage_members, { members: %i[index new create update destroy autocomplete_for_member] }, permissible_on: :project, diff --git a/config/locales/en.yml b/config/locales/en.yml index 15b593c5579c..5a80eb9ce436 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -288,6 +288,11 @@ en: no_results_content_text: Create a new work package category custom_fields: no_results_title_text: There are currently no custom fields available. + project_custom_fields: + header: + title: "Project attributes" + description: "These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes. +Project attributes and sections are defined in the administration settings by the administrator of the instance. " types: no_results_title_text: There are currently no types available. form: diff --git a/config/routes.rb b/config/routes.rb index 1f57c092e58a..8731ab72c901 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -185,6 +185,7 @@ resource :general, only: %i[show], controller: 'general' resource :modules, only: %i[show update] resource :types, only: %i[show update] + resource :project_custom_fields, only: %i[show update] resource :custom_fields, only: %i[show update] resource :repository, only: %i[show], controller: 'repository' resource :versions, only: %i[show] From 703c446be2772d14cc3c134d8c4776adf58389ff Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 28 Nov 2023 15:05:55 +0100 Subject: [PATCH 011/218] disable auto enabling of new project custom fields --- app/models/project_custom_field.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index c11c4e00cf0d..3df3dd8ae1c3 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -33,7 +33,7 @@ class ProjectCustomField < CustomField acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] - after_create :activate_in_all_projects + # after_create :activate_in_all_projects def type_name :label_project_plural @@ -47,9 +47,9 @@ def self.visible(user = User.current) end end - def activate_in_all_projects - # until we have the project mapping UI, activate the custom field in all projects - mappings = Project.active.map { |project| { project_id: project.id, custom_field_id: id } } - ProjectCustomFieldProjectMapping.create!(mappings) - end + # TODO: check if custom field is set to be activated in all projects in creation form + # def activate_in_all_projects + # mappings = Project.active.map { |project| { project_id: project.id, custom_field_id: id } } + # ProjectCustomFieldProjectMapping.create!(mappings) + # end end From 358e77eeb731c770a7dd27ac0ec32bd74da7b093 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 28 Nov 2023 15:15:00 +0100 Subject: [PATCH 012/218] minor cleanup --- .../custom_field_row_component.html.erb | 9 ++++- .../dialog_body_form_component.html.erb | 0 .../dialog_body_form_component.rb | 40 +++++++++---------- .../show_component.html.erb | 8 ++-- .../header_component.html.erb | 2 +- .../component_streams.rb | 2 +- 6 files changed, 31 insertions(+), 30 deletions(-) rename app/components/settings/{project_attributes/section => project_custom_field_sections}/dialog_body_form_component.html.erb (100%) rename app/components/settings/{project_attributes/section => project_custom_field_sections}/dialog_body_form_component.rb (58%) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index dbc4685be729..8c474e943eae 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -5,9 +5,14 @@ content_container.with_column(mr: 2) do render(Primer::OpenProject::DragHandle.new(classes: 'handle')) end - content_container.with_column do + content_container.with_column(mr: 2) do render(Primer::Beta::Text.new(font_weight: :bold)) do - "#{@project_custom_field.position_in_custom_field_section} #{@project_custom_field.name}" + @project_custom_field.name + end + end + content_container.with_column do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize end end end diff --git a/app/components/settings/project_attributes/section/dialog_body_form_component.html.erb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb similarity index 100% rename from app/components/settings/project_attributes/section/dialog_body_form_component.html.erb rename to app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb diff --git a/app/components/settings/project_attributes/section/dialog_body_form_component.rb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb similarity index 58% rename from app/components/settings/project_attributes/section/dialog_body_form_component.rb rename to app/components/settings/project_custom_field_sections/dialog_body_form_component.rb index b358be6d90de..1e4243574b46 100644 --- a/app/components/settings/project_attributes/section/dialog_body_form_component.rb +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb @@ -27,32 +27,30 @@ #++ module Settings - module ProjectAttributes - module Section - class DialogBodyFormComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - include OpTurbo::Streamable + module ProjectCustomFieldSections + class DialogBodyFormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable - def initialize(project_custom_field_section: ProjectCustomFieldSection.new) - super + def initialize(project_custom_field_section: ProjectCustomFieldSection.new) + super - @project_custom_field_section = project_custom_field_section - end + @project_custom_field_section = project_custom_field_section + end - private + private - def wrapper_uniq_by - @project_custom_field_section.id - end + def wrapper_uniq_by + @project_custom_field_section.id + end - def form_config - { - model: @project_custom_field_section, - method: @project_custom_field_section.persisted? ? :put : :post, - url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path - } - end + def form_config + { + model: @project_custom_field_section, + method: @project_custom_field_section.persisted? ? :put : :post, + url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path + } end end end diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index fca775745c3c..e87bee27605a 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -1,8 +1,6 @@ <%= component_wrapper do - render(Primer::Beta::BorderBox.new(mt: 3, data: { - id: @project_custom_field_section.id, 'allowed-drop-type': 'custom-field' - }.merge(drag_and_drop_target_config))) do |component| + render(Primer::Beta::BorderBox.new(mt: 3, data: drag_and_drop_target_config)) do |component| component.with_header(font_weight: :bold) do flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| @@ -11,7 +9,7 @@ end content_container.with_column do render(Primer::Beta::Text.new(font_weight: :bold)) do - "#{@project_custom_field_section.position} #{@project_custom_field_section.name}" + @project_custom_field_section.name end end end @@ -33,7 +31,7 @@ id: "project-custom-field-section-dialog#{@project_custom_field_section.id}", title: t('settings.project_attributes.label_new_section'), size: :medium_portrait )) do |dialog| - render(Settings::ProjectAttributes::Section::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) + render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) end end end diff --git a/app/components/settings/project_custom_fields/header_component.html.erb b/app/components/settings/project_custom_fields/header_component.html.erb index 8a590d5411f8..5bbe2bdcf676 100644 --- a/app/components/settings/project_custom_fields/header_component.html.erb +++ b/app/components/settings/project_custom_fields/header_component.html.erb @@ -32,7 +32,7 @@ button.with_leading_visual_icon(icon: :plus) t('settings.project_attributes.label_new_section') end - render(Settings::ProjectAttributes::Section::DialogBodyFormComponent.new()) + render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new()) end end diff --git a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb index e393a6a91627..a9b35de02cfa 100644 --- a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb @@ -49,7 +49,7 @@ def update_section_via_turbo_stream(project_custom_field_section:) def update_section_dialog_body_form_via_turbo_stream(project_custom_field_section:) update_via_turbo_stream( - component: ::Settings::ProjectAttributes::Section::DialogBodyFormComponent.new( + component: ::Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new( project_custom_field_section: ) ) From f3b0460e5a153bc5d13e92cd0d1a4603ce824d84 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 28 Nov 2023 16:03:38 +0100 Subject: [PATCH 013/218] quick fix issue when no custom field is enabled for a project --- .../sidebar_component.html.erb | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb index 43669a2f995a..94307c34265f 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -1,14 +1,16 @@ <%= content_tag("turbo-frame", id: "project-attributes-sidebar") do component_wrapper do - flex_layout do |sections_container| - @active_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| - sections_container.with_row(mb: 3) do - render(ProjectCustomFields::Sections::ShowComponent.new( - project: @project, - project_custom_field_section: get_eager_loaded_project_custom_field_section(project_custom_field_section_id), - project_custom_fields: project_custom_fields - )) + if @active_project_custom_fields_grouped_by_section.any? + flex_layout do |sections_container| + @active_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| + sections_container.with_row(mb: 3) do + render(ProjectCustomFields::Sections::ShowComponent.new( + project: @project, + project_custom_field_section: get_eager_loaded_project_custom_field_section(project_custom_field_section_id), + project_custom_fields: project_custom_fields + )) + end end end end From 15048a0914e7cf32c5286821e826031214f52bda Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 29 Nov 2023 10:54:29 +0100 Subject: [PATCH 014/218] setup auto-scroller for project custom field admin page as suggested by @oliverguenther --- .../dynamic/generic-drag-and-drop.controller.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts index 315ecc6e9eb2..2a0eb2ecaf6b 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts @@ -61,6 +61,22 @@ export default class extends Controller { // eslint-disable-next-line @typescript-eslint/no-misused-promises .on('drag', this.drag.bind(this)) .on('drop', this.drop.bind(this)); + + // Setup autoscroll + void window.OpenProject.getPluginContext().then((pluginContext) => { + // eslint-disable-next-line no-new + new pluginContext.classes.DomAutoscrollService( + [ + document.getElementById('content-wrapper') as HTMLElement, + ], + { + margin: 25, + maxSpeed: 10, + scrollWhenOutside: true, + autoScroll: () => this.drake?.dragging, + }, + ); + }); } reInitDrakeContainers() { From 458cffa7167d981e1f1ff3684cd9b3ddd952e5a8 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 29 Nov 2023 12:46:51 +0100 Subject: [PATCH 015/218] added client side project custom field mapping filter via stimulus --- .../custom_field_row_component.rb | 4 + .../index_component.html.erb | 20 ++++- .../index_component.rb | 7 ++ .../show_component.html.erb | 2 +- .../component_streams.rb | 14 ++++ .../project_custom_fields_controller.rb | 7 +- config/locales/en.yml | 4 +- ...custom-fields-mapping-filter.controller.ts | 73 +++++++++++++++++++ 8 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index e2ac8ff65d4c..4186ad637aa3 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -44,6 +44,10 @@ def initialize(project:, project_custom_field:, project_custom_field_project_map private + def wrapper_uniq_by + @project_custom_field.id + end + def active_in_project? @project_custom_field_project_mappings.any? do |mapping| mapping.custom_field_id == @project_custom_field.id diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb index 0cf52308aad9..9e23142ac9e7 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -1,6 +1,24 @@ <%= - component_wrapper do + component_wrapper(data: wrapper_data_attributes) do flex_layout do |flex| + flex.with_row(mt: 3) do + render(Primer::Alpha::TextField.new( + name: "project-custom-fields-mapping-filter", + label: t('projects.settings.project_custom_fields.filter.label'), + visually_hide_label: true, + placeholder: t('projects.settings.project_custom_fields.filter.label'), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: "project-custom-fields-mapping-filter-clear-button", + data: { + action: "input->projects--settings--project-custom-fields-mapping-filter#filterLists", + "projects--settings--project-custom-fields-mapping-filter-target": "filter" + } + )) + end @project_custom_fields_grouped_by_section.each do |section_id, project_custom_fields| flex.with_row do render(Projects::Settings::ProjectCustomFieldSections::ShowComponent.new( diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb index bba76f062bdb..d635000fc713 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -50,6 +50,13 @@ def initialize( private + def wrapper_data_attributes + { + controller: 'projects--settings--project-custom-fields-mapping-filter', + 'application-target': 'dynamic' + } + end + def get_eager_loaded_project_custom_field_section(section_id) @project_custom_field_sections.find { |section| section.id == section_id } end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 75485fdaf7fd..36bfcb3e4fd9 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -32,7 +32,7 @@ end else sorted_project_custom_fields.each do |project_custom_field| - component.with_row do + component.with_row(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'searchItem' }) do render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( project: @project, project_custom_field:, diff --git a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb index 1e1f1cd5f2d2..8bb0724cf735 100644 --- a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb @@ -48,6 +48,20 @@ def update_sections_via_turbo_stream( ) ) end + + def update_custom_field_row_via_turbo_stream( + project: @project, + project_custom_field: @project_custom_field, + project_custom_field_project_mappings: @project_custom_field_project_mappings + ) + update_via_turbo_stream( + component: ::Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( + project:, + project_custom_field:, + project_custom_field_project_mappings: + ) + ) + end end end end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 77fe7cdfe1ab..bff13cc704e0 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -39,11 +39,14 @@ class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsCont def show; end def update + @project_custom_field = ProjectCustomField.find(params[:project_custom_field_id]) + mapping = ProjectCustomFieldProjectMapping.find_or_initialize_by( project_id: @project.id, - custom_field_id: params[:project_custom_field_id] + custom_field_id: @project_custom_field.id ) + # toggle mapping if mapping.persisted? mapping.destroy! else @@ -52,7 +55,7 @@ def update eager_load_project_custom_field_project_mappings # reload mappings - update_sections_via_turbo_stream + update_custom_field_row_via_turbo_stream respond_with_turbo_streams end diff --git a/config/locales/en.yml b/config/locales/en.yml index 43b57a0997ff..ecf07ecdd564 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -293,6 +293,8 @@ en: title: "Project attributes" description: "These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes. Project attributes and sections are defined in the administration settings by the administrator of the instance. " + filter: + label: "Search project attribute" types: no_results_title_text: There are currently no types available. form: @@ -3136,7 +3138,7 @@ Project attributes and sections are defined in the - %{plural} are always attached to a project. + %{plural} are always attached to a project. You can only select projects here where the %{plural} module is active. After creating a %{singular} you can add work packages from other projects to it. public: "Publish this view, allowing other users to access your view. Users with the 'Manage public views' permission can modify or remove public query. This does not affect the visibility of work package results in that view and depending on their permissions, users may see different results." diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts new file mode 100644 index 000000000000..43a7b97aa39c --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts @@ -0,0 +1,73 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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'; + +export default class ProjectCustomFieldsMappingFilterController extends Controller { + static targets = [ + 'filter', + 'searchItem', + ]; + + declare readonly filterTarget:HTMLInputElement; + declare readonly searchItemTargets:HTMLInputElement[]; + + connect(): void { + this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.addEventListener('click', () => { + this.resetFilter(); + }); + } + + disconnect(): void { + this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.removeEventListener('click', () => { + this.resetFilter(); + }); + } + + filterLists(event: Event) { + const query = this.filterTarget.value.toLowerCase(); + + this.searchItemTargets.forEach((item) => { + const text = item.textContent!.toLowerCase(); + + if (text.includes(query)) { + (item as HTMLElement).style.display = "block"; + } else { + (item as HTMLElement).style.display = "none"; + } + }); + } + + resetFilter() { + this.searchItemTargets.forEach((item) => { + (item as HTMLElement).style.display = "block"; + }); + } +} From 566d71aff25363101c2211b9f92f3955a7840796 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 29 Nov 2023 12:51:46 +0100 Subject: [PATCH 016/218] added missing format display to project custom field mapping --- .../custom_field_row_component.html.erb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index dbe6078f60d3..0afde3639e8e 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -22,11 +22,16 @@ )) end end - custom_field_container.with_column do + custom_field_container.with_column(mr: 2) do render(Primer::Beta::Text.new(font_weight: :bold)) do @project_custom_field.name end end + custom_field_container.with_column do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize + end + end end end %> From 84dd14df94aace56f26e033cc93797a9f1a01013 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Dec 2023 12:43:14 +0100 Subject: [PATCH 017/218] temporary fix of overview page width issue seen in firefox --- .../grids/grid/page/grid-page.component.html | 4 ++-- .../grids/grid/page/grid-page.component.sass | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index 202bcfc39fdd..8b721a6702b0 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -21,8 +21,8 @@

-
-
+
+
diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass index f0353432f683..e475329f3199 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass @@ -13,10 +13,20 @@ &--toolbar-items padding-right: 20px + &--grid-container + display: grid + // grid-template-columns: auto 324px + // firefox issue when using auto or 1fr + // inner content width of the main-content is calculated dynamically + // chrome and firefox seem to come to different results + // temporary fix: use more specific width rules, e.g. 75% and 25% + // same issue with flex-box approach + grid-template-columns: 75% 25% + gap: 20px + &--sidebar margin-top: 20px - margin-right: 20px - width: 324px + margin-right: 40px @media only screen and (max-width: $breakpoint-sm) .op-grid-page--title-container From e1c9266eaeb26655c9f23d885e24e7ef54dbe66d Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Dec 2023 13:23:47 +0100 Subject: [PATCH 018/218] fixed responsive behavior and non-sidebar support --- frontend/src/app/features/overview/overview.component.ts | 2 ++ .../components/grids/grid/page/grid-page.component.html | 8 ++++++-- .../components/grids/grid/page/grid-page.component.sass | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index 139b1d753fdd..d4644234f89b 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -14,6 +14,8 @@ export class OverviewComponent extends GridPageComponent { } protected isTurboFrameSidebarEnabled():boolean { + // TODO: check if any project attributes are enabled for this project + // if not, don't show the sidebar return true; } diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index 8b721a6702b0..adec64191cbe 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -21,11 +21,11 @@

-
+
-
+
@@ -40,4 +40,8 @@

+
+ +
+
diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass index e475329f3199..70df6265df3f 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass @@ -32,3 +32,12 @@ .op-grid-page--title-container margin-left: 0 margin-bottom: 0 + +@media only screen and (max-width: $breakpoint-lg) + .op-grid-page--grid-container + grid-template-columns: 100% + gap: 0 + + .op-grid-page--sidebar + diplay: none + From 8c65e247d9dc75c85608f387aaf1a2f6f63483da Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Dec 2023 13:34:54 +0100 Subject: [PATCH 019/218] enable reverting of elements if they are dropped outside of a valid target --- .../controllers/dynamic/generic-drag-and-drop.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts index 2a0eb2ecaf6b..81872c2e8aec 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts @@ -56,6 +56,7 @@ export default class extends Controller { { moves: (_el, _source, handle, _sibling) => !!handle?.classList.contains('octicon-grabber'), accepts: (el?: Element | null, target?: Element | null, source?: Element | null, sibling?: Element | null) => this.accepts(el!, target!, source!, sibling!), + revertOnSpill: true // enable reverting of elements if they are dropped outside of a valid target }, ) // eslint-disable-next-line @typescript-eslint/no-misused-promises From fe77f6bb1a3b622f7636c914f245cc482f80150e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Dec 2023 13:45:11 +0100 Subject: [PATCH 020/218] refined empty section behavior as requested --- .../show_component.html.erb | 19 ++++++++++++++++++- .../project_custom_fields_controller.rb | 2 +- config/locales/en.yml | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index e87bee27605a..bde18fd9458a 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -39,7 +39,24 @@ end if @project_custom_fields.empty? component.with_row(data: { 'empty-list-item': true }) do - render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } + flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| + empty_list_container.with_column(ml: 4, mr: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } + end + empty_list_container.with_column do + render(Primer::Beta::Button.new( + tag: :a, + href: new_admin_settings_project_custom_field_path( + type: "ProjectCustomField", custom_field_section_id: @project_custom_field_section.id + ), + scheme: :secondary, + data: { turbo: "false"} + )) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_attribute') + end + end + end end else @project_custom_fields.each do |project_custom_field| diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 430712d7a35b..67317050e226 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -54,7 +54,7 @@ def show end def new - @custom_field = ProjectCustomField.new + @custom_field = ProjectCustomField.new(custom_field_section_id: params[:custom_field_section_id]) respond_to :html end diff --git a/config/locales/en.yml b/config/locales/en.yml index ecf07ecdd564..e84bba3711c9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2963,6 +2963,7 @@ Project attributes and sections are defined in the
Date: Mon, 4 Dec 2023 13:50:28 +0100 Subject: [PATCH 021/218] edit project attribute via click on name in row --- .../custom_field_row_component.html.erb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index 8c474e943eae..e558b1d5915c 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -6,7 +6,13 @@ render(Primer::OpenProject::DragHandle.new(classes: 'handle')) end content_container.with_column(mr: 2) do - render(Primer::Beta::Text.new(font_weight: :bold)) do + render(Primer::Beta::Link.new( + href: edit_admin_settings_project_custom_field_path(@project_custom_field), + scheme: :primary, + underline: false, + font_weight: :bold, + data: { turbo: "false" } + )) do @project_custom_field.name end end From 2184c2a535a4f96336a68a94e4359d4d3af68e5d Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Dec 2023 13:52:35 +0100 Subject: [PATCH 022/218] fixed translation for section edit action --- .../settings/project_custom_field_sections/show_component.rb | 2 +- config/locales/en.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index 8001d6e75b27..00aa9ddfaa4e 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -97,7 +97,7 @@ def disabled_delete_action_item(menu) end def edit_action_item(menu) - menu.with_item(label: t("text_edit"), + menu.with_item(label: t("settings.project_attributes.label_edit_section"), tag: :button, content_arguments: { 'data-show-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}" }, value: "") do |item| diff --git a/config/locales/en.yml b/config/locales/en.yml index e84bba3711c9..ee446f332d99 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2960,6 +2960,7 @@ Project attributes and sections are defined in the Date: Tue, 5 Dec 2023 18:07:22 +0100 Subject: [PATCH 023/218] add bulk actions to project custom field mapping UI --- .../custom_field_row_component.html.erb | 8 +-- .../show_component.html.erb | 56 +++++++++++++------ .../project_custom_fields_controller.rb | 53 ++++++++++++++++-- config/initializers/permissions.rb | 2 +- config/locales/en.yml | 5 ++ config/routes.rb | 10 +++- ...custom-fields-mapping-filter.controller.ts | 28 +++++++++- 7 files changed, 132 insertions(+), 30 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index 0afde3639e8e..aae232542a07 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -5,19 +5,19 @@ if active_in_project? render(Primer::Beta::IconButton.new( tag: :a, - href: project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), + href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), scheme: :invisible, icon: 'check-circle', - 'aria-label': 'Active in this project, click to disable', + 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_single'), data: { 'turbo-method': :put, 'turbo-stream': true } )) else render(Primer::Beta::IconButton.new( tag: :a, - href: project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), + href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), scheme: :invisible, icon: 'circle', - 'aria-label': 'Inactive in this project, click to enable', + 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_single'), data: { 'turbo-method': :put, 'turbo-stream': true } )) end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 36bfcb3e4fd9..8fc6db3e704c 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -1,27 +1,49 @@ <%= component_wrapper do render(Primer::Beta::BorderBox.new(mt: 3)) do |component| - component.with_header(font_weight: :bold) do + component.with_header(font_weight: :bold, py: 2) do flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| - section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| - content_container.with_column do - render(Primer::Beta::Text.new(font_weight: :bold)) do - @project_custom_field_section.name - end + section_header_container.with_column(py: 2) do |content_container| + # adding py: 2 here to match the padding of the actions_container + # otherwise the header height changes when the actions gets hidden when filtering + render(Primer::Beta::Text.new(font_weight: :bold)) do + @project_custom_field_section.name end end section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| - actions_container.with_column do - # render(Primer::Alpha::ActionMenu.new) do |menu| - # menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_section_actions"), scheme: :invisible) - # edit_action_item(menu) - # move_actions(menu) - # if @project_custom_fields.empty? - # delete_action_item(menu) - # else - # disabled_delete_action_item(menu) - # end - # end + actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: enable_all_of_section_project_settings_project_custom_fields_path( + project_id: @project.id, + project_custom_field_section_id: @project_custom_field_section.id + ), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_all'), + data: { 'turbo-method': :put, 'turbo-stream': true } + )) do |button| + button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) + t('projects.settings.project_custom_fields.actions.label_enable_all') + end + end + actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: disable_all_of_section_project_settings_project_custom_fields_path( + project_id: @project.id, + project_custom_field_section_id: @project_custom_field_section.id + ), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_all'), + data: { 'turbo-method': :put, 'turbo-stream': true } + )) do |button| + button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) + t('projects.settings.project_custom_fields.actions.label_disable_all') + end end end end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index bff13cc704e0..cd71cfc6f455 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -32,13 +32,15 @@ class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsCont menu_item :settings_project_custom_fields - before_action :eager_load_project_custom_field_sections, only: %i[show update] - before_action :eager_load_project_custom_fields, only: %i[show update] - before_action :eager_load_project_custom_field_project_mappings, only: %i[show update] + before_action :eager_load_project_custom_field_sections, only: %i[show toggle enable_all_of_section disable_all_of_section] + before_action :eager_load_project_custom_fields, only: %i[show toggle enable_all_of_section disable_all_of_section] + before_action :eager_load_project_custom_field_project_mappings, + only: %i[show toggle enable_all_of_section disable_all_of_section] def show; end - def update + def toggle + # TODO: use service instead @project_custom_field = ProjectCustomField.find(params[:project_custom_field_id]) mapping = ProjectCustomFieldProjectMapping.find_or_initialize_by( @@ -60,6 +62,26 @@ def update respond_with_turbo_streams end + def enable_all_of_section + bulk_edit_mappings_per_section(params[:project_custom_field_section_id], :enable) + + eager_load_project_custom_field_project_mappings # reload mappings + + update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + + respond_with_turbo_streams + end + + def disable_all_of_section + bulk_edit_mappings_per_section(params[:project_custom_field_section_id], :disable) + + eager_load_project_custom_field_project_mappings # reload mappings + + update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + + respond_with_turbo_streams + end + private def eager_load_project_custom_field_sections @@ -78,4 +100,27 @@ def eager_load_project_custom_field_project_mappings .where(project_id: @project.id) .to_a end + + def bulk_edit_mappings_per_section(section_id, action = :enable) + # TODO: use service instead + section = ProjectCustomFieldSection.find(section_id) + + # TODO: refactor this to use a single database query + section.custom_fields.each do |pcf| + mapping = ProjectCustomFieldProjectMapping.find_or_initialize_by( + project_id: @project.id, + custom_field_id: pcf.id + ) + + if action == :enable + unless mapping.persisted? + mapping.save! + end + elsif action == :disable + if mapping.persisted? + mapping.destroy! + end + end + end + end end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 374ad7c98ee2..c7ede71e125c 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -111,7 +111,7 @@ map.permission :select_project_custom_fields, { - 'projects/settings/project_custom_fields': %i[show update] + 'projects/settings/project_custom_fields': %i[show toggle enable_all_of_section disable_all_of_section] }, permissible_on: :project, require: :member diff --git a/config/locales/en.yml b/config/locales/en.yml index 7c5582fbdb61..78f88aaa518f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -293,6 +293,11 @@ en: Project attributes and sections are defined in the administration settings by the administrator of the instance. " filter: label: "Search project attribute" + actions: + label_enable_single: "Active in this project, click to disable" + label_disable_single: "Inactive in this project, click to enable" + label_enable_all: "Enable all" + label_disable_all: "Disable all" types: no_results_title_text: There are currently no types available. form: diff --git a/config/routes.rb b/config/routes.rb index 46d161f1fa42..772a2f7f7df4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -185,7 +185,15 @@ resource :general, only: %i[show], controller: 'general' resource :modules, only: %i[show update] resource :types, only: %i[show update] - resource :project_custom_fields, only: %i[show update] + resource :project_custom_fields, only: %i[show] do + member do + put :toggle + end + collection do + put :enable_all_of_section + put :disable_all_of_section + end + end resource :custom_fields, only: %i[show update] resource :repository, only: %i[show], controller: 'repository' resource :versions, only: %i[show] diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts index 43a7b97aa39c..5f0415210306 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts @@ -34,26 +34,34 @@ export default class ProjectCustomFieldsMappingFilterController extends Controll static targets = [ 'filter', 'searchItem', + 'bulkActionContainer' ]; declare readonly filterTarget:HTMLInputElement; declare readonly searchItemTargets:HTMLInputElement[]; + declare readonly bulkActionContainerTargets:HTMLInputElement[]; connect(): void { this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.addEventListener('click', () => { - this.resetFilter(); + this.resetFilterViaClearButton(); }); } disconnect(): void { this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.removeEventListener('click', () => { - this.resetFilter(); + this.resetFilterViaClearButton(); }); } filterLists(event: Event) { const query = this.filterTarget.value.toLowerCase(); + if (query.length > 0) { + this.hideBulkActionContainers(); + } else { + this.showBulkActionContainers(); + } + this.searchItemTargets.forEach((item) => { const text = item.textContent!.toLowerCase(); @@ -65,9 +73,23 @@ export default class ProjectCustomFieldsMappingFilterController extends Controll }); } - resetFilter() { + resetFilterViaClearButton() { + this.showBulkActionContainers(); + this.searchItemTargets.forEach((item) => { (item as HTMLElement).style.display = "block"; }); } + + hideBulkActionContainers() { + this.bulkActionContainerTargets.forEach((item) => { + (item as HTMLElement).style.display = "none"; + }); + } + + showBulkActionContainers() { + this.bulkActionContainerTargets.forEach((item) => { + (item as HTMLElement).style.display = "block"; + }); + } } From 5d97caf300f75cdb5e1e3add9ac34db75dc3ff87 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 6 Dec 2023 10:48:38 +0100 Subject: [PATCH 024/218] added project count on global project attributes settings page --- .../custom_field_row_component.html.erb | 7 ++++++- .../custom_field_row_component.rb | 10 ++++++++++ .../admin/settings/project_custom_fields_controller.rb | 4 +++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index e558b1d5915c..d376d4ebcb39 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -16,11 +16,16 @@ @project_custom_field.name end end - content_container.with_column do + content_container.with_column(mr: 2) do render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do @project_custom_field.field_format.capitalize end end + content_container.with_column do + render(Primer::Beta::Text.new(font_size: :small)) do + project_count_text + end + end end main_container.with_column do render(Primer::Alpha::ActionMenu.new) do |menu| diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index b36e95c8ef7f..3dc7976b6d76 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -79,6 +79,16 @@ def delete_action_item(menu) item.with_leading_visual_icon(icon: :trash) end end + + def project_count_text + project_count = @project_custom_field.project_custom_field_project_mappings.size + + if project_count == 1 + "#{project_count} #{t('activerecord.models.project')}" + else + "#{project_count} #{t('label_project_plural')}" + end + end end end end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 67317050e226..617b6cc23cac 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -116,7 +116,9 @@ def index_path(params = {}) end def set_sections - @project_custom_field_sections = ProjectCustomFieldSection.all + @project_custom_field_sections = ProjectCustomFieldSection + .includes(custom_fields: :project_custom_field_project_mappings) + .all end def find_custom_field From ce8a66edfc00ea0363318094a733960a977408ee Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 6 Dec 2023 14:01:00 +0100 Subject: [PATCH 025/218] minor refactoring --- .../custom_field_row_component.rb | 17 +++++++++++++---- .../show_component.rb | 13 ++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index 3dc7976b6d76..1edf7fb943c2 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -50,10 +50,19 @@ def edit_action_item(menu) end def move_actions(menu) - move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") unless @project_custom_field.first? - move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @project_custom_field.first? - move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") unless @project_custom_field.last? - unless @project_custom_field.last? + # TODO: these methods trigger database queries for each custom field displayed + # it would be nice if can eager load this information + first_in_list = @project_custom_field.first? + last_in_list = @project_custom_field.last? + + unless first_in_list + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), + "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last_in_list + move_action_item(menu, :lower, t("label_agenda_item_move_down"), + "chevron-down") move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") end diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index 00aa9ddfaa4e..573cf1d4c87b 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -64,16 +64,19 @@ def draggable_item_config(project_custom_field) end def move_actions(menu) - unless @project_custom_field_section.first? + # TODO: these methods trigger database queries for each section displayed + # it would be nice if can eager load this information + first_in_list = @project_custom_field_section.first? + last_in_list = @project_custom_field_section.last? + + unless first_in_list move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") end - move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") unless @project_custom_field_section.first? - unless @project_custom_field_section.last? + unless last_in_list move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") - end - unless @project_custom_field_section.last? move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") end From 2eb825e5cb4aadca8d136c727b44dedad5b09a8e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 6 Dec 2023 14:13:38 +0100 Subject: [PATCH 026/218] switched to whole button instead of icon_button for toggling project custom field mappings --- .../custom_field_row_component.html.erb | 27 ++----------------- .../custom_field_row_component.rb | 13 +++++++++ 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index aae232542a07..b2f20592c100 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -1,31 +1,8 @@ <%= component_wrapper do flex_layout(align_items: :center) do |custom_field_container| - custom_field_container.with_column(mr: 2) do - if active_in_project? - render(Primer::Beta::IconButton.new( - tag: :a, - href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), - scheme: :invisible, - icon: 'check-circle', - 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_single'), - data: { 'turbo-method': :put, 'turbo-stream': true } - )) - else - render(Primer::Beta::IconButton.new( - tag: :a, - href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), - scheme: :invisible, - icon: 'circle', - 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_single'), - data: { 'turbo-method': :put, 'turbo-stream': true } - )) - end - end - custom_field_container.with_column(mr: 2) do - render(Primer::Beta::Text.new(font_weight: :bold)) do - @project_custom_field.name - end + custom_field_container.with_column(mr: 1) do + toggle_button end custom_field_container.with_column do render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 4186ad637aa3..8161c9f254e3 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -53,6 +53,19 @@ def active_in_project? mapping.custom_field_id == @project_custom_field.id end end + + def toggle_button + render(Primer::Beta::Button.new( + tag: :a, + href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, + project_custom_field_id: @project_custom_field.id), + scheme: :invisible, + data: { 'turbo-method': :put, 'turbo-stream': true } + )) do |button| + button.with_leading_visual_icon(icon: (active_in_project? ? 'check-circle' : 'circle')) + @project_custom_field.name + end + end end end end From ea80fa944170f00918166e607de6b012354521cf Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Dec 2023 15:47:13 +0100 Subject: [PATCH 027/218] enable status code usage when responding with turbo streams --- app/controllers/concerns/op_turbo/component_stream.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 585c2411c729..0597d3b22100 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -30,13 +30,13 @@ module OpTurbo module ComponentStream extend ActiveSupport::Concern - def respond_to_with_turbo_streams(&format_block) + def respond_to_with_turbo_streams(status: :ok, &format_block) respond_to do |format| format.turbo_stream do render turbo_stream: turbo_streams, status: end - format_block.call(format) if block_given? + yield(format) if format_block end end alias_method :respond_with_turbo_streams, :respond_to_with_turbo_streams From 390d1bef3986576d8a8da3c4bd399cee9478b150 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Dec 2023 15:47:50 +0100 Subject: [PATCH 028/218] refactored project attributes update via project overview page dialogs and enable reset of multi value custom fields --- .../sections/edit_dialog_component.rb | 30 +-- .../show_component.html.erb | 4 +- .../project_custom_fields/show_component.rb | 32 ++- .../sections/show_component.html.erb | 2 +- .../sections/show_component.rb | 4 +- .../overviews/overviews_controller.rb | 237 ++++++++++++------ 6 files changed, 210 insertions(+), 99 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb index d914a9975eb7..6e7fd1df73af 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb @@ -70,38 +70,40 @@ def render_custom_field_value_input(form, custom_field, custom_field_values) end def render_single_value_custom_field_input(form, custom_field, custom_field_value) + form_args = { custom_field:, custom_field_value:, project: @project } + case custom_field.field_format when "string" - render(Project::CustomValueForm::String.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::String.new(form, **form_args)) when "text" - render(Project::CustomValueForm::Text.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::Text.new(form, **form_args)) when "int" - render(Project::CustomValueForm::Int.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::Int.new(form, **form_args)) when "float" - render(Project::CustomValueForm::Float.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::Float.new(form, **form_args)) when "list" - render(Project::CustomValueForm::SingleSelectList.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::SingleSelectList.new(form, **form_args)) when "date" - render(Project::CustomValueForm::Date.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::Date.new(form, **form_args)) when "bool" - render(Project::CustomValueForm::Bool.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::Bool.new(form, **form_args)) when "user" - render(Project::CustomValueForm::SingleUserSelectList.new(form, custom_field:, custom_field_value:, project: @project)) + render(Project::CustomValueForm::SingleUserSelectList.new(form, **form_args)) when "version" - render(Project::CustomValueForm::SingleVersionSelectList.new(form, custom_field:, custom_field_value:, - project: @project)) + render(Project::CustomValueForm::SingleVersionSelectList.new(form, **form_args)) end end def render_multi_value_custom_field_input(form, custom_field, custom_field_values) + form_args = { custom_field:, custom_field_values:, project: @project } + case custom_field.field_format when "list" - render(Project::CustomValueForm::MultiSelectList.new(form, custom_field:, custom_field_values:, project: @project)) + render(Project::CustomValueForm::MultiSelectList.new(form, **form_args)) when "user" - render(Project::CustomValueForm::MultiUserSelectList.new(form, custom_field:, custom_field_values:, project: @project)) + render(Project::CustomValueForm::MultiUserSelectList.new(form, **form_args)) when "version" - render(Project::CustomValueForm::MultiVersionSelectList.new(form, custom_field:, custom_field_values:, - project: @project)) + render(Project::CustomValueForm::MultiVersionSelectList.new(form, **form_args)) end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb index ae66097c7c2f..c4a0fc060476 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb @@ -6,10 +6,10 @@ end custom_field_value_container.with_row do - if @project_custom_field_value.blank? + if @project_custom_field_values.empty? render(Primer::Beta::Text.new(color: :subtle)) { t('label_not_set_yet') } else - render(Primer::Beta::Text.new()) { formated_value } + render(Primer::Beta::Text.new()) { render_formated_value } end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb index 602bb6f18203..0a877733c754 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb @@ -33,25 +33,43 @@ class ShowComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - def initialize(project_custom_field:, project_custom_field_value:) + def initialize(project_custom_field:, project_custom_field_values:) super @project_custom_field = project_custom_field - @project_custom_field_value = project_custom_field_value + @project_custom_field_values = project_custom_field_values end private - def formated_value - return if @project_custom_field_value.blank? + def render_formated_value + return if @project_custom_field_values.empty? + if @project_custom_field_values.one? + render_single_value(@project_custom_field_values.first) + else + render_multiple_values(@project_custom_field_values) + end + end + + def render_single_value(value) + formated_value(value) + end + + def render_multiple_values(values) + values.map do |value| + formated_value(value) + end.join(", ") + end + + def formated_value(value) case @project_custom_field.field_format when "text" - ::OpenProject::TextFormatting::Renderer.format_text(@project_custom_field_value.typed_value) + ::OpenProject::TextFormatting::Renderer.format_text(value.typed_value) when "date" - format_date(@project_custom_field_value.typed_value) + format_date(value.typed_value) else - @project_custom_field_value.typed_value&.to_s + value.typed_value&.to_s end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 6a7f26a3d00a..66c999d7ae60 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -24,7 +24,7 @@ details_container.with_row(mb: 3) do render(ProjectCustomFields::Sections::ProjectCustomFields::ShowComponent.new( project_custom_field: project_custom_field, - project_custom_field_value: get_eager_loaded_project_custom_field_value_for(project_custom_field.id) + project_custom_field_values: get_eager_loaded_project_custom_field_values_for(project_custom_field.id) )) end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb index d74633508007..45a49107b7d5 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb @@ -60,8 +60,8 @@ def sorted_project_custom_fields @project_custom_fields.sort_by { |pcf| pcf.position_in_custom_field_section } end - def get_eager_loaded_project_custom_field_value_for(custom_field_id) - @eager_loaded_project_custom_field_values.find { |pcfv| pcfv.custom_field_id == custom_field_id } + def get_eager_loaded_project_custom_field_values_for(custom_field_id) + @eager_loaded_project_custom_field_values.select { |pcfv| pcfv.custom_field_id == custom_field_id } end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index e09105c6d52d..35b431f3264b 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -20,11 +20,8 @@ def attributes_sidebar def attribute_section_dialog section = ProjectCustomFieldSection.find(params[:section_id]) - active_project_custom_fields_of_section = active_project_custom_fields_grouped_by_section[section.id] - .sort_by(&:position_in_custom_field_section) - eager_loaded_project_custom_field_values = CustomValue.where( - custom_field_id: active_project_custom_fields_of_section.pluck(:id), + custom_field_id: active_project_custom_fields_of_section(section.id).pluck(:id), customized_id: @project.id ).to_a @@ -32,7 +29,7 @@ def attribute_section_dialog ProjectCustomFields::Sections::EditDialogComponent.new( project: @project, project_custom_field_section: section, - active_project_custom_fields_of_section:, + active_project_custom_fields_of_section: active_project_custom_fields_of_section(section.id), project_custom_field_values: eager_loaded_project_custom_field_values ), layout: false @@ -40,85 +37,27 @@ def attribute_section_dialog end def update_attributes - # prototypical implementation - # manual nested attributes update as the project model is not yet natively supporting it - # needs refactoring - - section = ProjectCustomFieldSection.find(params[:section_id]) - - active_project_custom_fields_of_section = active_project_custom_fields_grouped_by_section[section.id] - .sort_by(&:position_in_custom_field_section) - - modified_custom_field_values = [] + section = find_project_custom_field_section has_errors = false ActiveRecord::Base.transaction do - # transaction to rollback if any of the custom field values fails to save - project_attribute_params[:custom_field_values_attributes]&.each do |custom_value_id, attributes| - custom_value = CustomValue.find(custom_value_id.to_i) - - custom_value.value = attributes[:value] - has_errors = true if custom_value.invalid? - modified_custom_field_values << custom_value - end - - project_attribute_params[:new_custom_field_values_attributes]&.each do |custom_field_id, attributes| - custom_value = CustomValue.new( - custom_field_id: custom_field_id.to_i, - value: attributes[:value], - customized_type: "Project", - customized_id: @project.id - ) + modified_custom_field_values = update_custom_field_values(section) + modified_custom_field_values = mark_required_missing_custom_values(section, modified_custom_field_values) - has_errors = true if custom_value.invalid? - modified_custom_field_values << custom_value - end - - # TODO: Cannot detect if all values are removed from a multi value custom field - # autocompleter does not send '_blank' as value when no option is selected as configured - project_attribute_params[:multi_custom_field_values_attributes]&.each do |custom_field_id, attributes| - # Detect removed values - @project.custom_values - .where(custom_field_id: custom_field_id.to_i) - .where.not(value: attributes[:values]) - .destroy_all - - attributes[:values]&.each do |value| - custom_value = CustomValue.find_or_initialize_by( - custom_field_id: custom_field_id.to_i, - value:, - customized_type: "Project", - customized_id: @project.id - ) - - has_errors = true if custom_value.invalid? - modified_custom_field_values << custom_value - end - end + has_errors = modified_custom_field_values.any?(&:invalid?) if has_errors - update_via_turbo_stream( - component: ProjectCustomFields::Sections::EditDialogComponent.new( - project: @project, - project_custom_field_section: section, - active_project_custom_fields_of_section:, - project_custom_field_values: modified_custom_field_values - ) - ) + handle_errors(section, modified_custom_field_values) else - modified_custom_field_values.each(&:save!) - update_via_turbo_stream( - component: ProjectCustomFields::SidebarComponent.new( - project: @project, - project_custom_field_sections: ProjectCustomFieldSection.all, - active_project_custom_fields_grouped_by_section: - ) - ) + save_custom_field_values(modified_custom_field_values) + delete_missing_custom_field_values(section, modified_custom_field_values) + + update_sidebar_component end end - respond_with_turbo_streams + respond_to_with_turbo_streams(status: has_errors ? :unprocessable_entity : :ok) end def jump_to_project_menu_item @@ -150,5 +89,157 @@ def active_project_custom_fields_grouped_by_section .sort_by { |pcf| pcf.project_custom_field_section.position } .group_by(&:custom_field_section_id) end + + def active_project_custom_fields_of_section(section_id) + active_project_custom_fields_grouped_by_section[section_id] + .sort_by(&:position_in_custom_field_section) + end + + def find_project_custom_field_section + ProjectCustomFieldSection.find(params[:section_id]) + end + + def update_custom_field_values(section) + custom_field_values = [] + + transaction_custom_field_values(section, :custom_field_values_attributes) do |custom_value_id, attributes| + custom_value = update_custom_value(custom_value_id, attributes) + custom_field_values << custom_value + end + + transaction_custom_field_values(section, :new_custom_field_values_attributes) do |custom_field_id, attributes| + custom_value = build_new_custom_value(custom_field_id, attributes) + custom_field_values << custom_value + end + + transaction_custom_field_values(section, :multi_custom_field_values_attributes) do |custom_field_id, attributes| + custom_field_values.concat(update_multi_custom_field_values(custom_field_id, attributes)) + end + + custom_field_values + end + + def transaction_custom_field_values(_section, attribute_key) + project_attribute_params[attribute_key]&.each do |custom_value_id, attributes| + yield(custom_value_id.to_i, attributes) + end + end + + def update_custom_value(custom_value_id, attributes) + custom_value = CustomValue.find(custom_value_id.to_i) + custom_value.value = attributes[:value] + custom_value + end + + def build_new_custom_value(custom_field_id, attributes) + CustomValue.new( + custom_field_id: custom_field_id.to_i, + value: attributes[:value], + customized_type: "Project", + customized_id: @project.id + ) + end + + def update_multi_custom_field_values(custom_field_id, attributes) + custom_field_values = [] + + existing_values_to_keep = attributes[:values] || [] + remove_unused_multi_values(custom_field_id, existing_values_to_keep) + + existing_values_to_keep.each do |value| + custom_value = find_or_initialize_custom_value(custom_field_id, value) + custom_field_values << custom_value + end + + custom_field_values + end + + def remove_unused_multi_values(custom_field_id, existing_values_to_keep) + @project.custom_values + .where(custom_field_id: custom_field_id.to_i) + .where.not(value: existing_values_to_keep) + .destroy_all + end + + def find_or_initialize_custom_value(custom_field_id, value) + CustomValue.find_or_initialize_by( + custom_field_id: custom_field_id.to_i, + value:, + customized_type: "Project", + customized_id: @project.id + ) + end + + def handle_errors(section, modified_custom_field_values) + update_via_turbo_stream( + component: ProjectCustomFields::Sections::EditDialogComponent.new( + project: @project, + project_custom_field_section: section, + active_project_custom_fields_of_section: active_project_custom_fields_of_section(section.id), + project_custom_field_values: modified_custom_field_values + ) + ) + end + + def save_custom_field_values(modified_custom_field_values) + modified_custom_field_values.each(&:save!) + end + + def handle_missing_values(section, modified_custom_field_values) + mark_missing_values_as_required(section, modified_custom_field_values) + end + + def get_missing_custom_field_ids(section, modified_custom_field_values) + custom_field_ids_of_section = active_project_custom_fields_of_section(section.id).pluck(:id) + modified_custom_field_ids = modified_custom_field_values.pluck(:custom_field_id) + + custom_field_ids_of_section - modified_custom_field_ids + end + + def delete_missing_custom_field_values(section, modified_custom_field_values) + missing_custom_field_ids = get_missing_custom_field_ids(section, modified_custom_field_values) + + non_required_custom_field_ids = ProjectCustomField + .where(id: missing_custom_field_ids) + .where.not(is_required: true) + .pluck(:id) + + CustomValue + .where(custom_field_id: non_required_custom_field_ids, customized_id: @project.id) + .destroy_all + end + + def mark_required_missing_custom_values(section, modified_custom_field_values) + missing_custom_field_ids = get_missing_custom_field_ids(section, modified_custom_field_values) + + required_custom_field_ids = ProjectCustomField + .where(id: missing_custom_field_ids) + .where(is_required: true) + .pluck(:id) + + required_custom_field_ids.each do |custom_field_id| + custom_value = CustomValue.find_or_initialize_by( + custom_field_id: custom_field_id.to_i, + customized_type: "Project", + customized_id: @project.id + ) + custom_value.value = nil + custom_value.errors.add(:value, :blank) + + modified_custom_field_values << custom_value + end + + modified_custom_field_values + end + + def update_sidebar_component + update_via_turbo_stream( + component: ProjectCustomFields::SidebarComponent.new( + project: @project, + project_custom_field_sections: ProjectCustomFieldSection.all, + active_project_custom_fields_grouped_by_section: + ) + ) + end end end From 6209f1269e0f265f3366173c3a99091d144611d7 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 8 Dec 2023 12:20:35 +0100 Subject: [PATCH 029/218] fixed single user select list input scope --- .../single_user_select_list.rb | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb index 52471ef373c6..b2d85a6287b5 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb @@ -33,19 +33,19 @@ class Project::CustomValueForm::SingleUserSelectList < Project::CustomValueForm: def base_config super.merge({ - autocomplete_options: { - inputId: id, - placeholder: "Search for a user", - resource: 'users', - # filters: [{ name: 'type', operator: '=', values: ['User'] }, - # { name: 'id', operator: '!', values: [::Queries::Filters::MeValue::KEY] }], - searchKey: 'any_name_attribute', - inputName: name, - inputValue: @custom_field_value&.value&.to_i || '', - # focusDirectly: true, - # appendTo: 'body', - # disabled: @disabled - } - }) + autocomplete_options: { + inputId: id, + placeholder: "Search for a user", + resource: 'principals', + filters: [{ name: 'type', operator: '=', values: ['User'] }, + { name: 'member', operator: '=', values: ['1'] }], + searchKey: 'any_name_attribute', + inputName: name, + inputValue: @custom_field_value&.value&.to_i || '' + # focusDirectly: true, + # appendTo: 'body', + # disabled: @disabled + } + }) end end From 134d3b58f365cc51230f83eefe15cda198fe2cbf Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 8 Dec 2023 15:18:56 +0100 Subject: [PATCH 030/218] WIP: fixing user and multi value inputs --- .../overviews/overviews_controller.rb | 60 ++++++++++++++++--- .../custom_value_form/multi_select_list.rb | 14 ++++- .../multi_user_select_list.rb | 38 ++++++++---- .../multi_version_select_list.rb | 13 +++- .../single_user_select_list.rb | 26 +++++++- 5 files changed, 125 insertions(+), 26 deletions(-) diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 35b431f3264b..ce831151bf6e 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -42,8 +42,8 @@ def update_attributes has_errors = false ActiveRecord::Base.transaction do - modified_custom_field_values = update_custom_field_values(section) - modified_custom_field_values = mark_required_missing_custom_values(section, modified_custom_field_values) + modified_custom_field_values = modify_custom_field_values(section) + modified_custom_field_values = add_missing_required_custom_values(section, modified_custom_field_values) has_errors = modified_custom_field_values.any?(&:invalid?) @@ -52,6 +52,7 @@ def update_attributes else save_custom_field_values(modified_custom_field_values) delete_missing_custom_field_values(section, modified_custom_field_values) + delete_unused_multi_values(unused_multi_values(section)) update_sidebar_component end @@ -73,6 +74,7 @@ def project_attribute_params params.require(:project).permit( custom_field_values_attributes: [:value], new_custom_field_values_attributes: [:value], + multi_user_custom_field_values_attributes: [:custom_field_id, { comma_seperated_values: [] }], multi_custom_field_values_attributes: [:custom_field_id, { values: [] }] ) end @@ -99,7 +101,7 @@ def find_project_custom_field_section ProjectCustomFieldSection.find(params[:section_id]) end - def update_custom_field_values(section) + def modify_custom_field_values(section) custom_field_values = [] transaction_custom_field_values(section, :custom_field_values_attributes) do |custom_value_id, attributes| @@ -116,6 +118,24 @@ def update_custom_field_values(section) custom_field_values.concat(update_multi_custom_field_values(custom_field_id, attributes)) end + transaction_custom_field_values(section, :multi_user_custom_field_values_attributes) do |custom_field_id, attributes| + custom_field_values.concat(update_multi_user_custom_field_values(custom_field_id, attributes)) + end + + custom_field_values + end + + def unused_multi_values(section) + custom_field_values = [] + + transaction_custom_field_values(section, :multi_custom_field_values_attributes) do |custom_field_id, attributes| + custom_field_values.concat(detect_unused_multi_values(custom_field_id, attributes)) + end + + transaction_custom_field_values(section, :multi_user_custom_field_values_attributes) do |custom_field_id, attributes| + custom_field_values.concat(detect_unused_user_multi_values(custom_field_id, attributes)) + end + custom_field_values end @@ -144,7 +164,6 @@ def update_multi_custom_field_values(custom_field_id, attributes) custom_field_values = [] existing_values_to_keep = attributes[:values] || [] - remove_unused_multi_values(custom_field_id, existing_values_to_keep) existing_values_to_keep.each do |value| custom_value = find_or_initialize_custom_value(custom_field_id, value) @@ -154,11 +173,34 @@ def update_multi_custom_field_values(custom_field_id, attributes) custom_field_values end - def remove_unused_multi_values(custom_field_id, existing_values_to_keep) + def update_multi_user_custom_field_values(custom_field_id, attributes) + custom_field_values = [] + + existing_values_to_keep = attributes[:comma_seperated_values][0]&.split(',') || [] + + existing_values_to_keep.each do |value| + custom_value = find_or_initialize_custom_value(custom_field_id, value) + custom_field_values << custom_value + end + + custom_field_values + end + + def detect_unused_multi_values(custom_field_id, attributes) + existing_values_to_keep = attributes[:values] || [] + unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) + end + + def detect_unused_user_multi_values(custom_field_id, attributes) + existing_values_to_keep = attributes[:comma_seperated_values][0]&.split(',') || [] + unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) + end + + def unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) @project.custom_values .where(custom_field_id: custom_field_id.to_i) .where.not(value: existing_values_to_keep) - .destroy_all + .to_a end def find_or_initialize_custom_value(custom_field_id, value) @@ -209,7 +251,11 @@ def delete_missing_custom_field_values(section, modified_custom_field_values) .destroy_all end - def mark_required_missing_custom_values(section, modified_custom_field_values) + def delete_unused_multi_values(custom_values_to_be_deleted) + custom_values_to_be_deleted.each(&:destroy!) + end + + def add_missing_required_custom_values(section, modified_custom_field_values) missing_custom_field_ids = get_missing_custom_field_ids(section, modified_custom_field_values) required_custom_field_ids = ProjectCustomField diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb index 33f1b3b47141..70b75b193528 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb @@ -59,14 +59,22 @@ def base_config multiple: true, decorated: true, inputId: id, - inputName: name, + inputName: name }, - invalid: false, - validation_message: nil + invalid: invalid?, + validation_message: } end def name "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" end + + def invalid? + @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } + end + + def validation_message + @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? + end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb index 7ef4b1864b49..42c8433f31c9 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb @@ -47,28 +47,44 @@ def base_config placeholder: @custom_field.name, label: @custom_field.name, required: @custom_field.is_required?, - include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected autocomplete_options: { multiple: true, + # decorated: true, inputId: id, placeholder: "Search for users", - resource: 'users', - # filters: [{ name: 'type', operator: '=', values: ['User'] }, - # { name: 'id', operator: '!', values: [::Queries::Filters::MeValue::KEY] }], + resource: 'principals', + filters: [{ name: 'type', operator: '=', values: ['User'] }, + { name: 'member', operator: '=', values: ['1'] }], searchKey: 'any_name_attribute', inputName: name, - inputValue: 4, - # focusDirectly: true, - # appendTo: 'body', - # disabled: @disabled + inputValue: input_value }, - invalid: false, - validation_message: nil + invalid: invalid?, + validation_message: } end def name - "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" + "project[multi_user_custom_field_values_attributes][#{@custom_field.id}][comma_seperated_values][]" end + def input_value + "?#{input_values_filter}" + end + + def input_values_filter + user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } + id_filter = { "id" => { "operator" => "=", "values" => @custom_field_values.map(&:value) } } + + filters = [user_filter, id_filter] + URI.encode_www_form("filters" => filters.to_json) + end + + def invalid? + @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } + end + + def validation_message + @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? + end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb index c2ec51fb15c4..62b05cbc0fb1 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb @@ -59,10 +59,10 @@ def base_config multiple: true, decorated: true, inputId: id, - inputName: name, + inputName: name }, - invalid: false, - validation_message: nil + invalid: invalid?, + validation_message: } end @@ -70,4 +70,11 @@ def name "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" end + def invalid? + @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } + end + + def validation_message + @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? + end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb index b2d85a6287b5..f410dae1e5f3 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb @@ -41,11 +41,33 @@ def base_config { name: 'member', operator: '=', values: ['1'] }], searchKey: 'any_name_attribute', inputName: name, - inputValue: @custom_field_value&.value&.to_i || '' + inputValue: input_value # focusDirectly: true, # appendTo: 'body', # disabled: @disabled - } + }, + invalid: invalid?, + validation_message: }) end + + def input_value + "?#{input_values_filter}" + end + + def input_values_filter + user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } + id_filter = { "id" => { "operator" => "=", "values" => @custom_field_value.value } } + + filters = [user_filter, id_filter] + URI.encode_www_form("filters" => filters.to_json) + end + + def invalid? + @custom_field_value.errors.any? + end + + def validation_message + @custom_field_value.errors.full_messages.join(', ') if invalid? + end end From d38f22c9478593c5c8fbbbc0816794c94f72b3fb Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 18 Dec 2023 14:18:16 +0100 Subject: [PATCH 031/218] fixed redirect issue when deleting custom options --- .../settings/project_custom_fields_controller.rb | 8 -------- .../concerns/custom_fields/shared_actions.rb | 14 ++++++++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 617b6cc23cac..526c449e3bb5 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -107,14 +107,6 @@ def destroy private - def edit_path(id:) - admin_settings_project_custom_field_path(id:) - end - - def index_path(params = {}) - admin_settings_project_custom_fields_path(**params) - end - def set_sections @project_custom_field_sections = ProjectCustomFieldSection .includes(custom_fields: :project_custom_field_project_mappings) diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb index f84db04f3665..be8da8be6a4f 100644 --- a/app/controllers/concerns/custom_fields/shared_actions.rb +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -32,13 +32,19 @@ module SharedActions included do def index_path(params = {}) - # default path below, can be overridden in the including controller - custom_fields_path(**params) + if @custom_field.type == 'ProjectCustomField' + admin_settings_project_custom_fields_path(**params) + else + custom_fields_path(**params) + end end def edit_path(params = {}) - # default path below, can be overridden in the including controller - edit_custom_field_path(**params) + if @custom_field.type == 'ProjectCustomField' + admin_settings_project_custom_field_path(**params) + else + edit_custom_field_path(**params) + end end def create From 06bff13d6ee9d513f75241a8b0534deca0098a80 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 18 Dec 2023 15:33:26 +0100 Subject: [PATCH 032/218] reworked custom field form due to renderig errors when using rails partials within a view_component --- ...nent.html.erb => _form_component.html.erb} | 20 +++--- .../{form_component.rb => _form_component.rb} | 62 +++++++++++-------- .../concerns/custom_fields/shared_actions.rb | 16 ++--- .../project_custom_fields/edit.html.erb | 20 +++++- .../project_custom_fields/new.html.erb | 20 +++++- app/views/custom_fields/_form.html.erb | 22 +++---- 6 files changed, 98 insertions(+), 62 deletions(-) rename app/components/settings/project_custom_fields/{form_component.html.erb => _form_component.html.erb} (65%) rename app/components/settings/project_custom_fields/{form_component.rb => _form_component.rb} (52%) diff --git a/app/components/settings/project_custom_fields/form_component.html.erb b/app/components/settings/project_custom_fields/_form_component.html.erb similarity index 65% rename from app/components/settings/project_custom_fields/form_component.html.erb rename to app/components/settings/project_custom_fields/_form_component.html.erb index 700c6c860e31..d0219dd100d8 100644 --- a/app/components/settings/project_custom_fields/form_component.html.erb +++ b/app/components/settings/project_custom_fields/_form_component.html.erb @@ -26,19 +26,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= content_tag(:div, data: { - controller: 'admin--custom-fields', - 'application-target': 'dynamic', - 'admin--custom-fields-format-value': @custom_field.field_format - }) do %> - <%= error_messages_for 'custom_field' %> - - <%= labelled_tabular_form_for(@custom_field, **form_config) do |f| %> - <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> - <% if @custom_field.new_record? %> - <%= hidden_field_tag 'type', @custom_field.type %> - <% end %> - <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> +<%= error_messages_for 'custom_field' %> + +<%= labelled_tabular_form_for(@custom_field, **form_config) do |f| %> + <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> + <% if @custom_field.new_record? %> + <%= hidden_field_tag 'type', @custom_field.type %> <% end %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> <% end %> diff --git a/app/components/settings/project_custom_fields/form_component.rb b/app/components/settings/project_custom_fields/_form_component.rb similarity index 52% rename from app/components/settings/project_custom_fields/form_component.rb rename to app/components/settings/project_custom_fields/_form_component.rb index 211c8191756b..9baa0135b3e4 100644 --- a/app/components/settings/project_custom_fields/form_component.rb +++ b/app/components/settings/project_custom_fields/_form_component.rb @@ -25,32 +25,44 @@ # # See COPYRIGHT and LICENSE files for more details. #++ +# +# +# +# +# +# TODO: currently not working properly when using rails partials within the template of this component +# renders tags as strings instead of proper HTML tags within the custom options table +# using plain rails views for form rendering for now (tbd!) +# +# +# +# +# +# module Settings +# module ProjectCustomFields +# class FormComponent < ApplicationComponent +# include ApplicationHelper +# include CustomFieldsHelper +# include StimulusHelper +# include ErrorMessageHelper +# include OpenProject::FormTagHelper +# include OpPrimer::ComponentHelpers -module Settings - module ProjectCustomFields - class FormComponent < ApplicationComponent - include ApplicationHelper - include CustomFieldsHelper - include StimulusHelper - include ErrorMessageHelper - include OpenProject::FormTagHelper - include OpPrimer::ComponentHelpers - - def initialize(custom_field: ProjectCustomField.new) - super +# def initialize(custom_field: ProjectCustomField.new) +# super - @custom_field = custom_field - end +# @custom_field = custom_field +# end - private +# private - def form_config - { - as: :custom_field, - url: @custom_field.persisted? ? admin_settings_project_custom_field_path(@custom_field) : admin_settings_project_custom_fields_path, - html: { method: @custom_field.persisted? ? :put : :post, id: 'custom_field_form' } - } - end - end - end -end +# def form_config +# { +# as: :custom_field, +# url: @custom_field.persisted? ? admin_settings_project_custom_field_path(@custom_field) : admin_settings_project_custom_fields_path, +# html: { method: @custom_field.persisted? ? :put : :post, id: 'custom_field_form' } +# } +# end +# end +# end +# end diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb index be8da8be6a4f..1e8265f0e769 100644 --- a/app/controllers/concerns/custom_fields/shared_actions.rb +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -31,16 +31,16 @@ module SharedActions extend ActiveSupport::Concern included do - def index_path(params = {}) - if @custom_field.type == 'ProjectCustomField' + def index_path(custom_field, params = {}) + if custom_field.type == 'ProjectCustomField' admin_settings_project_custom_fields_path(**params) else custom_fields_path(**params) end end - def edit_path(params = {}) - if @custom_field.type == 'ProjectCustomField' + def edit_path(custom_field, params = {}) + if custom_field.type == 'ProjectCustomField' admin_settings_project_custom_field_path(**params) else edit_custom_field_path(**params) @@ -55,7 +55,7 @@ def create if call.success? flash[:notice] = t(:notice_successful_create) call_hook(:controller_custom_fields_new_after_save, custom_field: call.result) - redirect_to index_path(tab: call.result.class.name) + redirect_to index_path(call.result, tab: call.result.class.name) else @custom_field = call.result || new_custom_field render action: 'new' @@ -74,7 +74,7 @@ def perform_update(custom_field_params) if call.success? flash[:notice] = t(:notice_successful_update) call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field) - redirect_back_or_default(edit_path(id: @custom_field.id)) + redirect_back_or_default(edit_path(@custom_field, id: @custom_field.id)) else render action: 'edit' end @@ -98,7 +98,7 @@ def destroy rescue StandardError flash[:error] = I18n.t(:error_can_not_delete_custom_field) end - redirect_to index_path(tab: @custom_field.class.name) + redirect_to index_path(@custom_field, tab: @custom_field.class.name) end def delete_option @@ -112,7 +112,7 @@ def delete_option flash[:error] = @custom_option.errors.full_messages end - redirect_to edit_path(id: @custom_field.id) + redirect_to edit_path(@custom_field, id: @custom_field.id) end def new_custom_field diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb index b3f3bc19b06e..7b24dc2c02a6 100644 --- a/app/views/admin/settings/project_custom_fields/edit.html.erb +++ b/app/views/admin/settings/project_custom_fields/edit.html.erb @@ -26,6 +26,24 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> +<% content_controller 'admin--custom-fields', + dynamic: true, + 'admin--custom-fields-format-value': @custom_field.field_format +%> + <%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field)) %> -<%= render(Settings::ProjectCustomFields::FormComponent.new(custom_field: @custom_field)) %> + +<%= error_messages_for 'custom_field' %> + +<%= labelled_tabular_form_for @custom_field, as: :custom_field, + url: admin_settings_project_custom_field_path(@custom_field), + html: {method: :put, id: 'custom_field_form'} do |f| %> + <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> + <% if @custom_field.new_record? %> + <%= hidden_field_tag 'type', @custom_field.type %> + <% end %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> +<% end %> + + diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb index bc254c2f1cd2..781470cc8a99 100644 --- a/app/views/admin/settings/project_custom_fields/new.html.erb +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -26,5 +26,21 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new()) %> -<%= render(Settings::ProjectCustomFields::FormComponent.new(custom_field: @custom_field)) %> +<% content_controller 'admin--custom-fields', + dynamic: true, + 'admin--custom-fields-format-value': @custom_field.field_format +%> + +<%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new) %> + +<%= error_messages_for 'custom_field' %> + +<%= labelled_tabular_form_for @custom_field, as: :custom_field, + url: admin_settings_project_custom_fields_path, + html: { id: 'custom_field_form' } do |f| %> + <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> + <% if @custom_field.new_record? %> + <%= hidden_field_tag 'type', @custom_field.type %> + <% end %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> +<% end %> diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index 7b2068246421..a137f0431fea 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -26,10 +26,6 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> - - -<% @custom_field = custom_field if @custom_field.nil? %> -
<%= f.text_field :name, required: true, container_class: '-middle' %> @@ -105,15 +101,15 @@ See COPYRIGHT and LICENSE files for more details. <% end %>
<%= render partial: "custom_fields/custom_options", locals: { custom_field: @custom_field, f: f } %> - +
<% end %> From 4cdd1fe6c406c460baf26d86d527b8b701519bd5 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 4 Jan 2024 16:01:41 +0100 Subject: [PATCH 033/218] increase pp instance size --- .github/workflows/pullpreview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index b60c278a68eb..dc1c49054027 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -45,7 +45,7 @@ jobs: with: admins: crohr,HDinger,machisuji,oliverguenther,ulferts,wielinde,cbliard compose_files: docker-compose.pullpreview.yml - instance_type: large + instance_type: xlarge ports: 80,443,8080 default_port: 443 ttl: 10d From 66d6089f751bcd42aa44791e6b78ecf5a7e74e45 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 4 Jan 2024 16:48:27 +0100 Subject: [PATCH 034/218] remove file with commented out code tripping up Zeitwerk --- .../_form_component.html.erb | 38 ----------- .../project_custom_fields/_form_component.rb | 68 ------------------- 2 files changed, 106 deletions(-) delete mode 100644 app/components/settings/project_custom_fields/_form_component.html.erb delete mode 100644 app/components/settings/project_custom_fields/_form_component.rb diff --git a/app/components/settings/project_custom_fields/_form_component.html.erb b/app/components/settings/project_custom_fields/_form_component.html.erb deleted file mode 100644 index d0219dd100d8..000000000000 --- a/app/components/settings/project_custom_fields/_form_component.html.erb +++ /dev/null @@ -1,38 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) 2012-2023 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. - -++#%> -<%= error_messages_for 'custom_field' %> - -<%= labelled_tabular_form_for(@custom_field, **form_config) do |f| %> - <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> - <% if @custom_field.new_record? %> - <%= hidden_field_tag 'type', @custom_field.type %> - <% end %> - <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> -<% end %> - diff --git a/app/components/settings/project_custom_fields/_form_component.rb b/app/components/settings/project_custom_fields/_form_component.rb deleted file mode 100644 index 9baa0135b3e4..000000000000 --- a/app/components/settings/project_custom_fields/_form_component.rb +++ /dev/null @@ -1,68 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2023 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. -#++ -# -# -# -# -# -# TODO: currently not working properly when using rails partials within the template of this component -# renders tags as strings instead of proper HTML tags within the custom options table -# using plain rails views for form rendering for now (tbd!) -# -# -# -# -# -# module Settings -# module ProjectCustomFields -# class FormComponent < ApplicationComponent -# include ApplicationHelper -# include CustomFieldsHelper -# include StimulusHelper -# include ErrorMessageHelper -# include OpenProject::FormTagHelper -# include OpPrimer::ComponentHelpers - -# def initialize(custom_field: ProjectCustomField.new) -# super - -# @custom_field = custom_field -# end - -# private - -# def form_config -# { -# as: :custom_field, -# url: @custom_field.persisted? ? admin_settings_project_custom_field_path(@custom_field) : admin_settings_project_custom_fields_path, -# html: { method: @custom_field.persisted? ? :put : :post, id: 'custom_field_form' } -# } -# end -# end -# end -# end From 5b79645ac44597cf5ac4ad5cf8355468ceb0d402 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 9 Jan 2024 18:33:58 +0100 Subject: [PATCH 035/218] added feature flag and fixed minor bugs --- .../custom_field_row_component.html.erb | 6 ++- .../custom_field_row_component.rb | 3 +- .../show_component.html.erb | 8 ++-- config/initializers/feature_decisions.rb | 1 + config/initializers/menus.rb | 45 ++++++++++++------- .../features/overview/overview.component.ts | 4 +- .../grids/grid/page/grid-page.component.ts | 7 ++- .../show_component.html.erb | 14 ++++-- .../project_custom_fields/show_component.rb | 38 ++++++---------- .../sections/show_component.html.erb | 8 +++- .../sidebar_component.html.erb | 2 +- .../overviews/overviews_controller.rb | 6 +++ 12 files changed, 84 insertions(+), 58 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index b2f20592c100..279fd2284176 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -1,10 +1,12 @@ <%= component_wrapper do - flex_layout(align_items: :center) do |custom_field_container| + flex_layout(align_items: :center, classes: 'op-project-custom-field', data: { + qa_selector: "project-custom-field-#{@project_custom_field.id}" + }) do |custom_field_container| custom_field_container.with_column(mr: 1) do toggle_button end - custom_field_container.with_column do + custom_field_container.with_column(data: { qa_selector: "custom-field-type" } ) do render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do @project_custom_field.field_format.capitalize end diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 8161c9f254e3..1b78fa474b06 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -60,7 +60,8 @@ def toggle_button href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, project_custom_field_id: @project_custom_field.id), scheme: :invisible, - data: { 'turbo-method': :put, 'turbo-stream': true } + data: { 'turbo-method': :put, 'turbo-stream': true, + qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" } )) do |button| button.with_leading_visual_icon(icon: (active_in_project? ? 'check-circle' : 'circle')) @project_custom_field.name diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 8fc6db3e704c..db840362ceb8 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -1,6 +1,8 @@ <%= component_wrapper do - render(Primer::Beta::BorderBox.new(mt: 3)) do |component| + render(Primer::Beta::BorderBox.new(mt: 3, classes: 'op-project-custom-field-section', data: { + qa_selector: "project-custom-field-section-#{@project_custom_field_section.id}" + })) do |component| component.with_header(font_weight: :bold, py: 2) do flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| section_header_container.with_column(py: 2) do |content_container| @@ -22,7 +24,7 @@ font_weight: :bold, color: :subtle, 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_all'), - data: { 'turbo-method': :put, 'turbo-stream': true } + data: { 'turbo-method': :put, 'turbo-stream': true, qa_selector: "enable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } )) do |button| button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) t('projects.settings.project_custom_fields.actions.label_enable_all') @@ -39,7 +41,7 @@ font_weight: :bold, color: :subtle, 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_all'), - data: { 'turbo-method': :put, 'turbo-stream': true } + data: { 'turbo-method': :put, 'turbo-stream': true, qa_selector: "disable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } )) do |button| button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) t('projects.settings.project_custom_fields.actions.label_disable_all') diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 401b2feb8311..01ea7cebbf5a 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -40,3 +40,4 @@ # end OpenProject::FeatureDecisions.add :work_package_sharing +OpenProject::FeatureDecisions.add :project_attributes diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index c836e456ecc9..e74536420cec 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -37,7 +37,7 @@ caption: I18n.t('label_projects_menu'), icon: 'projects', if: Proc.new { - (User.current.logged? || !Setting.login_required?) + User.current.logged? || !Setting.login_required? } menu.push :activity, @@ -139,7 +139,7 @@ icon: 'projects', after: :home, if: Proc.new { - (User.current.logged? || !Setting.login_required?) + User.current.logged? || !Setting.login_required? } menu.push :projects_query_select, @@ -330,17 +330,25 @@ caption: Proc.new { Workflow.model_name.human }, parent: :admin_work_packages - menu.push :admin_projects_settings, - { controller: '/admin/settings/project_custom_fields', action: :index }, - if: Proc.new { User.current.admin? }, - caption: :label_project_plural, - icon: 'projects' - - menu.push :project_custom_fields_settings, - { controller: '/admin/settings/project_custom_fields', action: :index }, - if: Proc.new { User.current.admin? }, - caption: :label_project_attributes_plural, - parent: :admin_projects_settings + if OpenProject::FeatureDecisions.project_attributes_active? + menu.push :admin_projects_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_plural, + icon: 'projects' + + menu.push :project_custom_fields_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_attributes_plural, + parent: :admin_projects_settings + else + menu.push :admin_projects_settings, + { controller: '/admin/settings/projects_settings', action: :show }, + if: Proc.new { User.current.admin? }, + caption: :label_project_plural, + icon: 'projects' + end menu.push :projects_settings, { controller: '/admin/settings/projects_settings', action: :show }, @@ -607,9 +615,8 @@ icon: 'settings2', allow_deeplink: true - { + project_menu_items = { general: :label_information_plural, - project_custom_fields: :label_project_attributes_plural, modules: :label_module_plural, types: :label_work_package_types, custom_fields: :label_custom_field_plural, @@ -618,7 +625,13 @@ repository: :label_repository, time_entry_activities: :enumeration_activities, storage: :label_required_disk_storage - }.each do |key, caption| + } + + if OpenProject::FeatureDecisions.project_attributes_active? + project_menu_items = project_menu_items.to_a.insert(1, %i[project_custom_fields label_project_attributes_plural]).to_h + end + + project_menu_items.each do |key, caption| menu.push :"settings_#{key}", { controller: "/projects/settings/#{key}", action: 'show' }, caption:, diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index d4644234f89b..d0de3b2f2b8f 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -14,9 +14,7 @@ export class OverviewComponent extends GridPageComponent { } protected isTurboFrameSidebarEnabled():boolean { - // TODO: check if any project attributes are enabled for this project - // if not, don't show the sidebar - return true; + return this.configurationService.activeFeatureFlags.includes('projectAttributes') } protected turboFrameSidebarSrc():string { diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts index 56cf054414df..912cf8fa278f 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts @@ -9,6 +9,7 @@ import { GridResource } from 'core-app/features/hal/resources/grid-resource'; import { GridAddWidgetService } from 'core-app/shared/components/grids/grid/add-widget.service'; import { GridAreaService } from 'core-app/shared/components/grids/grid/area.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { ConfigurationService } from 'core-app/core/config/configuration.service'; @Directive() export abstract class GridPageComponent implements OnInit, OnDestroy { @@ -26,11 +27,13 @@ export abstract class GridPageComponent implements OnInit, OnDestroy { readonly title:Title, readonly addWidget:GridAddWidgetService, readonly renderer:Renderer2, - readonly areas:GridAreaService) {} - + readonly areas:GridAreaService, + readonly configurationService:ConfigurationService) {} + public grid:GridResource; protected isTurboFrameSidebarEnabled():boolean { + // may be overridden by subclasses return false; } diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb index c4a0fc060476..09204591f3f7 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb @@ -1,15 +1,21 @@ <%= - flex_layout(align_items: :flex_start, justify_content: :space_between) do |custom_field_value_container| + flex_layout(align_items: :flex_start, justify_content: :space_between, classes: 'op-project-custom-field-container', data: { + qa_selector: "project-custom-field-#{@project_custom_field.id}" + }) do |custom_field_value_container| # temporarily using inline styles in order to align the content as desired custom_field_value_container.with_row(mb: 1) do render(Primer::Beta::Text.new(font_weight: :bold)) { @project_custom_field.name } end custom_field_value_container.with_row do - if @project_custom_field_values.empty? - render(Primer::Beta::Text.new(color: :subtle)) { t('label_not_set_yet') } + if not_set? + if @project_custom_field.default_value.present? + render(Primer::Beta::Text.new()) { render_formatted_default_value } + else + render(Primer::Beta::Text.new()) { t('label_not_set_yet') } + end else - render(Primer::Beta::Text.new()) { render_formated_value } + render(Primer::Beta::Text.new()) { render_formatted_value } end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb index 0a877733c754..f3e4e0babfe1 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb @@ -31,6 +31,7 @@ module Sections module ProjectCustomFields class ShowComponent < ApplicationComponent include ApplicationHelper + include CustomFieldsHelper include OpPrimer::ComponentHelpers def initialize(project_custom_field:, project_custom_field_values:) @@ -42,35 +43,24 @@ def initialize(project_custom_field:, project_custom_field_values:) private - def render_formated_value - return if @project_custom_field_values.empty? + def render_formatted_value + @project_custom_field_values&.map do |cf_value| + format_value(cf_value.value, @project_custom_field) + end&.join(", ")&.html_safe + end - if @project_custom_field_values.one? - render_single_value(@project_custom_field_values.first) + def render_formatted_default_value + if @project_custom_field.default_value.is_a?(Array) + @project_custom_field.default_value.map do |default_value| + format_value(default_value, @project_custom_field) + end.join(", ").html_safe else - render_multiple_values(@project_custom_field_values) + format_value(@project_custom_field.default_value, @project_custom_field) end end - def render_single_value(value) - formated_value(value) - end - - def render_multiple_values(values) - values.map do |value| - formated_value(value) - end.join(", ") - end - - def formated_value(value) - case @project_custom_field.field_format - when "text" - ::OpenProject::TextFormatting::Renderer.format_text(value.typed_value) - when "date" - format_date(value.typed_value) - else - value.typed_value&.to_s - end + def not_set? + @project_custom_field_values.empty? || @project_custom_field_values.all? { |cf_value| cf_value.value.blank? } end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 66c999d7ae60..44012a99acbe 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -1,6 +1,8 @@ <%= component_wrapper do - flex_layout(border: :bottom, pb: 2) do |details_container| + flex_layout(border: :bottom, pb: 2, classes: 'op-project-custom-field-section-container', data: { + qa_selector: "project-custom-field-section-#{@project_custom_field_section.id}" + }) do |details_container| details_container.with_row(mb: 2) do flex_layout(align_items: :center, justify_content: :space_between) do |heading| heading.with_column(flex: 1) do @@ -14,7 +16,9 @@ size: :medium_portrait, title: "#{t('label_edit')} #{ @project_custom_field_section.name }", button_icon: :pencil, - button_attributes: { scheme: :invisible } + button_attributes: { scheme: :invisible, data: { + qa_selector: "project-custom-field-section-edit-button" + } } )) end end diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb index 94307c34265f..ebbe0447486c 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -2,7 +2,7 @@ content_tag("turbo-frame", id: "project-attributes-sidebar") do component_wrapper do if @active_project_custom_fields_grouped_by_section.any? - flex_layout do |sections_container| + flex_layout(data: { qa_selector: "project-attributes-sidebar-async-content" }) do |sections_container| @active_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| sections_container.with_row(mb: 3) do render(ProjectCustomFields::Sections::ShowComponent.new( diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index ce831151bf6e..9d8bd5342fb1 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -3,6 +3,8 @@ class OverviewsController < ::Grids::BaseInProjectController include OpTurbo::ComponentStream before_action :jump_to_project_menu_item + before_action :check_project_attributes_feature_enabled, + only: %i[attributes_sidebar attribute_section_dialog update_attributes] menu_item :overview @@ -70,6 +72,10 @@ def jump_to_project_menu_item private + def check_project_attributes_feature_enabled + render_404 unless OpenProject::FeatureDecisions.project_attributes_active? + end + def project_attribute_params params.require(:project).permit( custom_field_values_attributes: [:value], From e7922b70ea87812ad0edea3ba6cdf5acc7903340 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 9 Jan 2024 18:34:55 +0100 Subject: [PATCH 036/218] introducing capybara specs for overview and project settings page --- spec/factories/custom_field_factory.rb | 16 + spec/factories/custom_field_section.rb | 37 + .../project_custom_field_project_mapping.rb | 34 + .../overview_page/edit_spec.rb | 59 ++ .../overview_page/overview_page.rb | 56 ++ .../overview_page/shared_context.rb | 177 ++++ .../overview_page/show_spec.rb | 850 ++++++++++++++++++ .../settings/mapping_spec.rb | 363 ++++++++ .../repositories/repository_settings_spec.rb | 16 +- 9 files changed, 1600 insertions(+), 8 deletions(-) create mode 100644 spec/factories/custom_field_section.rb create mode 100644 spec/factories/project_custom_field_project_mapping.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/edit_spec.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/overview_page.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/shared_context.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/show_spec.rb create mode 100644 spec/features/projects/project_custom_fields/settings/mapping_spec.rb diff --git a/spec/factories/custom_field_factory.rb b/spec/factories/custom_field_factory.rb index 44c88e2c7879..6913ce7b4bb9 100644 --- a/spec/factories/custom_field_factory.rb +++ b/spec/factories/custom_field_factory.rb @@ -158,6 +158,22 @@ end factory :project_custom_field, class: 'ProjectCustomField' do + project_custom_field_section + + transient do + projects { [] } + end + + # enable the the custom_field for the given projects + after(:create) do |custom_field, evaluator| + projects = Array(evaluator.projects) + next if projects.blank? + + projects.each do |project| + create(:project_custom_field_project_mapping, project:, project_custom_field: custom_field) + end + end + factory :boolean_project_custom_field, traits: [:boolean] factory :string_project_custom_field, traits: [:string] factory :text_project_custom_field, traits: [:text] diff --git a/spec/factories/custom_field_section.rb b/spec/factories/custom_field_section.rb new file mode 100644 index 000000000000..861b2f049d18 --- /dev/null +++ b/spec/factories/custom_field_section.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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. +#++ + +FactoryBot.define do + factory :custom_field_section do + sequence(:name) { |n| "Section No. #{n}" } + created_at { Time.zone.now } + updated_at { Time.zone.now } + + factory :project_custom_field_section, class: 'ProjectCustomFieldSection' + end +end diff --git a/spec/factories/project_custom_field_project_mapping.rb b/spec/factories/project_custom_field_project_mapping.rb new file mode 100644 index 000000000000..4564b7668d2c --- /dev/null +++ b/spec/factories/project_custom_field_project_mapping.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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. +#++ + +FactoryBot.define do + factory :project_custom_field_project_mapping do + project_custom_field + project + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb b/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb new file mode 100644 index 000000000000..1fbfd6d796b6 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb @@ -0,0 +1,59 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative 'shared_context' +require_relative 'overview_page' + +RSpec.describe 'Edit project custom fields on project overview page', :js, :with_cuprite do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { OverviewPage.new(project) } + + before do + login_as admin + end + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + describe 'with sufficient permissions' do + describe 'enables editing of project custom field values via dialog' do + it 'opens a dialog showing inputs for project custom fields of a specific section' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='project-custom-field-section-edit-button']").click + end + end + + expect(page).to have_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/overview_page.rb b/spec/features/projects/project_custom_fields/overview_page/overview_page.rb new file mode 100644 index 000000000000..d023fd74b2c7 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/overview_page.rb @@ -0,0 +1,56 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +class OverviewPage + include Rails.application.routes.url_helpers + include Capybara::DSL + include Capybara::RSpecMatchers + include RSpec::Matchers + + def initialize(project) + @project = project + end + + def visit_page + visit project_path(@project.id) + end + + def within_async_loaded_sidebar + within '#project-attributes-sidebar' do + expect(page).to have_css("[data-qa-selector='project-attributes-sidebar-async-content']") + yield + end + end + + def within_custom_field_section_container(section, &) + within("[data-qa-selector='project-custom-field-section-#{section.id}']", &) + end + + def within_custom_field_container(custom_field, &) + within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb new file mode 100644 index 000000000000..9053b5b014b8 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -0,0 +1,177 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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. +#++ + +RSpec.shared_context 'with seeded projects, members and project custom fields' do + let(:project) { create(:project, name: 'Foo project', identifier: 'foo-project') } + let(:other_project) { create(:project, name: 'Bar project', identifier: 'bar-project') } + + let(:first_version) { create(:version, name: 'Version 1', project:) } + let(:second_version) { create(:version, name: 'Version 2', project:) } + + let!(:admin) do + create(:admin) + end + + let!(:member_in_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In Project', + member_with_permissions: { project => %w[ + view_work_packages + ] }) + end + + let!(:another_member_in_project) do + create(:user, + firstname: 'Member 2', + lastname: 'In Project', + member_with_permissions: { project => %w[ + view_work_packages + ] }) + end + + let!(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } + let!(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } + let!(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } + + let!(:boolean_project_custom_field) do + field = create(:boolean_project_custom_field, projects: [project], name: 'Boolean field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: true) + + field + end + + let!(:string_project_custom_field) do + field = create(:string_project_custom_field, projects: [project], name: 'String field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: 'Foo') + + field + end + + let!(:integer_project_custom_field) do + field = create(:integer_project_custom_field, projects: [project], name: 'Integer field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: 123) + + field + end + + let!(:float_project_custom_field) do + field = create(:float_project_custom_field, projects: [project], name: 'Float field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: 123.456) + + field + end + + let!(:date_project_custom_field) do + field = create(:date_project_custom_field, projects: [project], name: 'Date field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: Date.new(2024, 1, 1)) + + field + end + + let!(:text_project_custom_field) do + field = create(:text_project_custom_field, projects: [project], name: 'Text field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: "Lorem\nipsum") + + field + end + + let!(:list_project_custom_field) do + field = create(:list_project_custom_field, projects: [project], name: 'List field', + project_custom_field_section: section_for_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3']) + + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first) + + field + end + + let!(:version_project_custom_field) do + field = create(:version_project_custom_field, projects: [project], name: 'Version field', + project_custom_field_section: section_for_select_fields) + + create(:custom_value, customized: project, custom_field: field, value: first_version.id) + + field + end + + let!(:user_project_custom_field) do + field = create(:user_project_custom_field, projects: [project], name: 'User field', + project_custom_field_section: section_for_select_fields) + + create(:custom_value, customized: project, custom_field: field, value: member_in_project.id) + + field + end + + let!(:multi_list_project_custom_field) do + field = create(:list_project_custom_field, projects: [project], name: 'Multi list field', + project_custom_field_section: section_for_multi_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3'], + multi_value: true) + + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first.id) + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.second.id) + + field + end + + let!(:multi_version_project_custom_field) do + field = create(:version_project_custom_field, projects: [project], name: 'Multi version field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + + create(:custom_value, customized: project, custom_field: field, value: first_version.id) + create(:custom_value, customized: project, custom_field: field, value: second_version.id) + + field + end + + let!(:multi_user_project_custom_field) do + field = create(:user_project_custom_field, projects: [project], name: 'Multi user field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + + create(:custom_value, customized: project, custom_field: field, value: member_in_project.id) + create(:custom_value, customized: project, custom_field: field, value: another_member_in_project.id) + + field + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/show_spec.rb b/spec/features/projects/project_custom_fields/overview_page/show_spec.rb new file mode 100644 index 000000000000..f41ab4d3ded2 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/show_spec.rb @@ -0,0 +1,850 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative 'shared_context' +require_relative 'overview_page' + +RSpec.describe 'Show project custom fields on project overview page', :js, :with_cuprite do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { OverviewPage.new(project) } + + before do + login_as admin + end + + describe 'with disabled project attributes feature', with_flag: { project_attributes: false } do + it 'does not show the project attributes sidebar' do + overview_page.visit_page + + within '.op-grid-page' do + expect(page).not_to have_css('#project-attributes-sidebar') + end + end + end + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + it 'does show the project attributes sidebar' do + overview_page.visit_page + + within '.op-grid-page' do + expect(page).to have_css('#project-attributes-sidebar') + end + end + + describe 'with correct scoping' do + it 'shows enabled project custom fields in a sidebar grouped by section' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + expect(page).to have_css('.op-project-custom-field-section-container', count: 3) + + overview_page.within_custom_field_section_container(section_for_input_fields) do + expect(page).to have_text 'Input fields' + + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'String field' + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Float field' + expect(page).to have_text 'Date field' + expect(page).to have_text 'Text field' + end + + overview_page.within_custom_field_section_container(section_for_select_fields) do + expect(page).to have_text 'Select fields' + + expect(page).to have_text 'List field' + expect(page).to have_text 'Version field' + expect(page).to have_text 'User field' + end + + overview_page.within_custom_field_section_container(section_for_multi_select_fields) do + expect(page).to have_text 'Multi select fields' + + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Multi version field' + expect(page).to have_text 'Multi user field' + end + end + end + + it 'does not show project custom fields not enabled for this project in a sidebar' do + create(:string_project_custom_field, projects: [other_project], name: 'String field enabled for other project') + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + expect(page).not_to have_text 'String field enabled for other project' + end + end + end + + describe 'with correct order' do + it 'shows the project custom field sections in the correct order' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + sections = page.all('.op-project-custom-field-section-container') + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include('Input fields') + expect(sections[1].text).to include('Select fields') + expect(sections[2].text).to include('Multi select fields') + end + + section_for_input_fields.move_to_bottom + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + sections = page.all('.op-project-custom-field-section-container') + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include('Select fields') + expect(sections[1].text).to include('Multi select fields') + expect(sections[2].text).to include('Input fields') + end + end + + it 'shows the project custom fields in the correct order within the sections' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all('.op-project-custom-field-container') + + expect(fields.size).to eq(6) + + expect(fields[0].text).to include('Boolean field') + expect(fields[1].text).to include('String field') + expect(fields[2].text).to include('Integer field') + expect(fields[3].text).to include('Float field') + expect(fields[4].text).to include('Date field') + expect(fields[5].text).to include('Text field') + end + end + + string_project_custom_field.move_to_bottom + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all('.op-project-custom-field-container') + + expect(fields.size).to eq(6) + + expect(fields[0].text).to include('Boolean field') + expect(fields[1].text).to include('Integer field') + expect(fields[2].text).to include('Float field') + expect(fields[3].text).to include('Date field') + expect(fields[4].text).to include('Text field') + expect(fields[5].text).to include('String field') + end + end + end + end + + describe 'with correct values' do + describe 'with boolean CF' do + # it_behaves_like 'a project custom field' do + # let(subject) { boolean_project_custom_field } + # end + + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Yes' + end + end + end + end + + describe 'with value unset by user' do + # A boolean cannot be completely unset via UI, only toggle between true and false, no blank value possible + before do + boolean_project_custom_field.custom_values.where(customized: project).first.update!(value: false) + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'No' + end + end + end + end + + describe 'with no value set by user' do + before do + boolean_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + boolean_project_custom_field.update!(default_value: true) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Yes' + end + end + + boolean_project_custom_field.update!(default_value: false) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'No' + end + end + end + end + end + + describe 'with string CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Foo' + end + end + end + end + + describe 'with value unset by user' do + before do + string_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + string_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + string_project_custom_field.update!(default_value: 'Bar') + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Bar' + end + end + end + end + end + + describe 'with integer CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text '123' + end + end + end + end + + describe 'with value unset by user' do + before do + integer_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + integer_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + integer_project_custom_field.update!(default_value: 456) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text '456' + end + end + end + end + end + + describe 'with date CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text '01/01/2024' + end + end + end + end + + describe 'with value unset by user' do + before do + date_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + date_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + date_project_custom_field.update!(default_value: Date.new(2024, 2, 2)) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text '02/02/2024' + end + end + end + end + end + + describe 'with float CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text '123.456' + end + end + end + end + + describe 'with value unset by user' do + before do + float_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + float_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + float_project_custom_field.update!(default_value: 456.789) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text '456.789' + end + end + end + end + end + + describe 'with text CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text "Lorem\nipsum" + end + end + end + end + + describe 'with value unset by user' do + before do + text_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + text_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + text_project_custom_field.update!(default_value: 'Dolor sit amet') + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text 'Dolor sit amet' + end + end + end + end + end + + describe 'with list CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Option 1' + end + end + end + end + + describe 'with value unset by user' do + before do + list_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + list_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value for the project custom field if no value given' do + list_project_custom_field.custom_options.first.update!(default_value: true) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Option 1' + end + end + end + end + end + + describe 'with version CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text 'Version field' + expect(page).to have_text 'Version 1' + end + end + end + end + + describe 'with value unset by user' do + before do + version_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text 'Version field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + version_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text 'Version field' + expect(page).to have_text 'Not set yet' + end + end + end + end + end + + describe 'with user CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text 'User field' + expect(page).to have_text 'Member 1 In Project' + end + end + end + end + + describe 'with value unset by user' do + before do + user_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end + + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text 'User field' + expect(page).to have_text 'Not set yet' + end + end + end + end + + describe 'with no value set by user' do + before do + user_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text 'User field' + expect(page).to have_text 'Not set yet' + end + end + end + end + end + + describe 'with multi list CF' do + describe 'with value set by user' do + it 'shows the correct values for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Option 1, Option 2' + end + end + end + end + + describe 'with no value set by user' do + before do + multi_list_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Not set yet' + end + end + end + + it 'shows the default value(s) for the project custom field if no value given' do + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Option 1, Option 2' + end + end + end + end + end + + describe 'with multi version CF' do + describe 'with value set by user' do + it 'shows the correct values for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_version_project_custom_field) do + expect(page).to have_text 'Multi version field' + expect(page).to have_text 'Version 1, Version 2' + end + end + end + end + + describe 'with no value set by user' do + before do + multi_version_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_version_project_custom_field) do + expect(page).to have_text 'Multi version field' + expect(page).to have_text 'Not set yet' + end + end + end + end + end + + describe 'with multi user CF' do + describe 'with value set by user' do + it 'shows the correct values for the project custom field if given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_user_project_custom_field) do + expect(page).to have_text 'Multi user field' + expect(page).to have_text 'Member 1 In Project, Member 2 In Project' + end + end + end + end + + describe 'with no value set by user' do + before do + multi_user_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_user_project_custom_field) do + expect(page).to have_text 'Multi user field' + expect(page).to have_text 'Not set yet' + end + end + end + end + end + end + end + + # TODO: share examples in order to reduce code duplication + # shared_examples 'a project custom field' do + # it 'shows the correct value for the project custom field if given' do + # overview_page.visit_page + + # overview_page.within_async_loaded_sidebar do + # overview_page.within_custom_field_container(custom_field) do + # expect(page).to have_text custom_field.name + # expect(page).to have_text custom_field.formatted_value + # end + # end + # end + # end +end diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb new file mode 100644 index 000000000000..f1bf221ad049 --- /dev/null +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -0,0 +1,363 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 'Projects custom fields mapping via project settings', :js, :with_cuprite do + let(:project) { create(:project, name: 'Foo project', identifier: 'foo-project') } + let(:other_project) { create(:project, name: 'Bar project', identifier: 'bar-project') } + + let!(:user_with_sufficient_permissions) do + create(:user, + firstname: 'Project', + lastname: 'Admin', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ], + other_project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + let!(:member_in_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In Project', + member_with_permissions: { project => %w[ + edit_project + view_work_packages + ] }) + end + + let!(:another_member_in_project) do + create(:user, + firstname: 'Member 2', + lastname: 'In Project', + member_with_permissions: { project => %w[ + view_work_packages + ] }) + end + + let!(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } + let!(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } + let!(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } + + let!(:boolean_project_custom_field) do + create(:boolean_project_custom_field, name: 'Boolean field', + project_custom_field_section: section_for_input_fields) + end + + let!(:string_project_custom_field) do + create(:string_project_custom_field, name: 'String field', + project_custom_field_section: section_for_input_fields) + end + + let!(:list_project_custom_field) do + create(:list_project_custom_field, name: 'List field', + project_custom_field_section: section_for_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3']) + end + + let!(:multi_list_project_custom_field) do + create(:list_project_custom_field, name: 'Multi list field', + project_custom_field_section: section_for_multi_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3'], + multi_value: true) + end + + describe 'with insufficient permissions' do + before do + login_as member_in_project # can edit project but is not allowed to select project custom fields + end + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + it 'does not show the menu entry in the project settings menu' do + visit project_settings_general_path(project) + + within '#menu-sidebar' do + expect(page).not_to have_css("li[data-name='settings_project_custom_fields']") + end + end + + it 'does not show the project custom fields page' do + visit project_settings_project_custom_fields_path(project) + + expect(page).to have_content('You are not authorized to access this page.') + end + end + end + + describe 'with sufficient permissions' do + before do + login_as user_with_sufficient_permissions + end + + describe 'with disabled project attributes feature', with_flag: { project_attributes: false } do + it 'does not show the menu entry in the project settings menu' do + visit project_settings_general_path(project) + + within '#menu-sidebar' do + expect(page).not_to have_css("li[data-name='settings_project_custom_fields']") + end + end + end + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + it 'does show the menu entry in the project settings menu' do + visit project_settings_general_path(project) + + within '#menu-sidebar' do + expect(page).to have_css("li[data-name='settings_project_custom_fields']") + end + end + + it 'shows all available project custom fields with their correct mapping state' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_content('Boolean field') + expect_type("Bool") + expect_unchecked_state + end + within_custom_field_container(string_project_custom_field) do + expect(page).to have_content('String field') + expect_type("String") + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect(page).to have_content('List field') + expect_type("List") + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_content('Multi list field') + expect_type("List") + expect_unchecked_state + end + end + end + + it 'toggles the mapping state of a project custom field for a specific project when clicked' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + + page.find("[data-qa-selector='toggle-project-custom-field-mapping-#{boolean_project_custom_field.id}']").click + + expect_checked_state # without reloading the page + end + end + + # propely persisted and visible after full page reload + visit project_settings_project_custom_fields_path(project) + + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + + # only for this project + visit project_settings_project_custom_fields_path(other_project) + + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + end + end + + it 'enables all mapping states of a section for a specific project when bulk action button clicked' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + within_custom_field_container(string_project_custom_field) do + expect_checked_state + end + end + + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect_unchecked_state + end + end + end + + it 'disables all mapping states of a section for a specific project when bulk action button clicked' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + within_custom_field_container(string_project_custom_field) do + expect_checked_state + end + end + + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + end + within_custom_field_container(string_project_custom_field) do + expect_unchecked_state + end + end + end + + it 'filters the project custom fields by name with given user input' do + visit project_settings_project_custom_fields_path(project) + + fill_in 'project-custom-fields-mapping-filter', with: 'Boolean' + + within_custom_field_section_container(section_for_input_fields) do + expect(page).to have_content('Boolean field') + expect(page).not_to have_content('String field') + end + + within_custom_field_section_container(section_for_select_fields) do + expect(page).not_to have_content('List field') + end + + within_custom_field_section_container(section_for_multi_select_fields) do + expect(page).not_to have_content('Multi list field') + end + end + + it 'shows the project custom field sections in the correct order' do + visit project_settings_project_custom_fields_path(project) + + sections = page.all('.op-project-custom-field-section') + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include('Input fields') + expect(sections[1].text).to include('Select fields') + expect(sections[2].text).to include('Multi select fields') + + section_for_input_fields.move_to_bottom + + visit project_settings_project_custom_fields_path(project) + + sections = page.all('.op-project-custom-field-section') + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include('Select fields') + expect(sections[1].text).to include('Multi select fields') + expect(sections[2].text).to include('Input fields') + end + + it 'shows the project custom fields in the correct order within the sections' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + custom_fields = page.all('.op-project-custom-field') + + expect(custom_fields.size).to eq(2) + + expect(custom_fields[0].text).to include('Boolean field') + expect(custom_fields[1].text).to include('String field') + end + + boolean_project_custom_field.move_to_bottom + + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + custom_fields = page.all('.op-project-custom-field') + + expect(custom_fields.size).to eq(2) + + expect(custom_fields[0].text).to include('String field') + expect(custom_fields[1].text).to include('Boolean field') + end + end + end + end + + def expect_type(type) + within "[data-qa-selector='custom-field-type']" do + expect(page).to have_content(type) + end + end + + def expect_checked_state + expect(page).to have_css('.octicon-check-circle') + end + + def expect_unchecked_state + expect(page).to have_css('.octicon-circle') + end + + def within_custom_field_section_container(section, &) + within("[data-qa-selector='project-custom-field-section-#{section.id}']", &) + end + + def within_custom_field_container(custom_field, &) + within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) + end +end diff --git a/spec/features/repositories/repository_settings_spec.rb b/spec/features/repositories/repository_settings_spec.rb index bc2a64c0db21..16ce9e714752 100644 --- a/spec/features/repositories/repository_settings_spec.rb +++ b/spec/features/repositories/repository_settings_spec.rb @@ -30,7 +30,7 @@ require 'features/repositories/repository_settings_page' require 'features/support/components/danger_zone' -RSpec.describe 'Repository Settings', js: true do +RSpec.describe 'Repository Settings', :js do let(:current_user) { create (:admin) } let(:project) { create(:project) } let(:settings_page) { RepositorySettingsPage.new(project) } @@ -54,7 +54,7 @@ shared_examples 'manages the repository' do |type| it 'displays the repository' do - expect(page).to have_selector('select[name="scm_vendor"]') + expect(page).to have_css('select[name="scm_vendor"]') expect(find("#attributes-group--content-#{type}", visible: true)) .not_to be_nil end @@ -84,7 +84,7 @@ else SeleniumHubWaiter.wait find('a.icon-remove', text: I18n.t(:button_remove)).click - expect(page).to have_selector('.op-toast.-warning') + expect(page).to have_css('.op-toast.-warning') SeleniumHubWaiter.wait find('a', text: I18n.t(:button_remove)).click end @@ -105,7 +105,7 @@ shared_examples 'manages the repository with' do |name, type, _repository_type, _project_name| let(:repository) do - create("repository_#{name}".to_sym, + create(:"repository_#{name}", scm_type: type, project:) end @@ -151,7 +151,7 @@ end end - context 'remote', webmock: true do + context 'remote', :webmock do let(:url) { 'http://myreposerver.example.com/api/' } let(:config) do { @@ -192,9 +192,9 @@ fill_in('repository-password-placeholder', with: 'password') click_button(I18n.t(:button_save)) - expect(page).to have_selector('[name="repository[login]"][value="foobar"]') - expect(page).to have_selector('.op-toast', - text: I18n.t('repositories.update_settings_successful')) + expect(page).to have_css('[name="repository[login]"][value="foobar"]') + expect(page).to have_css('.op-toast', + text: I18n.t('repositories.update_settings_successful')) end end end From 55e6deadd5121c0cfd742e2f5e1cbe07f9dee380 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 9 Jan 2024 18:40:42 +0100 Subject: [PATCH 037/218] updated Gemfile.lock --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0d95752e68b4..9864982cab5a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1278,4 +1278,4 @@ RUBY VERSION ruby 3.2.2p53 BUNDLED WITH - 2.4.22 + 2.4.7 From d7b8dc7b3d6bea53c283cc7b2484d98346fd84d7 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 9 Jan 2024 18:45:42 +0100 Subject: [PATCH 038/218] enabling project attributes feature for pullpreview env --- docker/pullpreview/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/pullpreview/docker-compose.yml b/docker/pullpreview/docker-compose.yml index f4879929d170..528af54590d9 100644 --- a/docker/pullpreview/docker-compose.yml +++ b/docker/pullpreview/docker-compose.yml @@ -25,6 +25,7 @@ x-defaults: &defaults - "OPENPROJECT_RAILS__CACHE__STORE=file_store" - "RAILS_ENV=production" - "SECRET_KEY_BASE=d4e74f017910ac56c6ebad01165b7e1b37f4c9c02e9716836f8670cdc8d65a231e64e4f6416b19c8" + - "OPENPROJECT_FEATURE_PROJECT_ATTRIBUTES_ACTIVE=true" networks: - backend From 8a6068f5b893307db788cf8ad7dafba4f6da2440 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 10 Jan 2024 14:27:11 +0100 Subject: [PATCH 039/218] adding more specs for project custom fields on overview page --- .../sections/edit_dialog_component.html.erb | 8 +- .../overview_page/edit_spec.rb | 112 +++++++++++++++++- .../overview_page/overview_page.rb | 18 +++ .../overview_page/shared_context.rb | 27 +++++ .../overview_page/show_spec.rb | 4 +- 5 files changed, 159 insertions(+), 10 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index 93ab06ee19cb..a2e06e95b846 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -11,22 +11,22 @@ collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 500px;")) do flex_layout(my: 3) do |flex| @active_project_custom_fields_of_section.each do |project_custom_field| - flex.with_row(mb: 2) do + flex.with_row(mb: 2, classes: 'op-project-custom-field-input-container', data: { qa_selector: "project-custom-field-input-container-#{project_custom_field.id}" }) do render_custom_field_value_input(f, project_custom_field, project_custom_field_values_for(project_custom_field.id)) end end end end collection.with_component(Primer::Alpha::Dialog::Footer.new) do - component_collection do |collection1| - collection1.with_component(Primer::ButtonComponent.new( + component_collection do |footer_collection| + footer_collection.with_component(Primer::ButtonComponent.new( data: { 'close-dialog-id': "edit-project-attributes-dialog-#{@project_custom_field_section.id}" } )) do t("button_cancel") end - collection1.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do + footer_collection.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do t("button_save") end end diff --git a/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb b/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb index 1fbfd6d796b6..414424a812dc 100644 --- a/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb @@ -45,13 +45,117 @@ it 'opens a dialog showing inputs for project custom fields of a specific section' do overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_section_container(section_for_input_fields) do - page.find("[data-qa-selector='project-custom-field-section-edit-button']").click + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + expect(page).to have_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") + end + + it 'renders the dialog body asynchronically' do + overview_page.visit_page + + expect(page).to have_no_css('#project-custom-fields-sections-edit-dialog-component', visible: :all) + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + expect(page).to have_css('#project-custom-fields-sections-edit-dialog-component', visible: :visible) + end + + it 'can be closed via close icon or cancel button' do + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + within("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") do + page.find(".close-button").click + end + + expect(page).to have_no_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + within("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") do + click_link_or_button 'Cancel' + end + + expect(page).to have_no_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") + end + + it 'shows only the project custom fields of the specific section within the dialog' do + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + overview_page.within_edit_dialog_for_section(section_for_input_fields) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if input_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end end end - expect(page).to have_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") + overview_page.close_edit_dialog_for_section(section_for_input_fields) + + overview_page.open_edit_dialog_for_section(section_for_select_fields) + + overview_page.within_edit_dialog_for_section(section_for_select_fields) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + overview_page.close_edit_dialog_for_section(section_for_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) + + overview_page.within_edit_dialog_for_section(section_for_multi_select_fields) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if multi_select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + end + + it 'shows the inputs in the correct order defined by the position of project custom field in a section' do + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + overview_page.within_edit_dialog_for_section(section_for_input_fields) do + fields = page.all('.op-project-custom-field-input-container') + + expect(fields[0].text).to include('Boolean field') + expect(fields[1].text).to include('String field') + expect(fields[2].text).to include('Integer field') + expect(fields[3].text).to include('Float field') + expect(fields[4].text).to include('Date field') + expect(fields[5].text).to include('Text field') + end + + overview_page.close_edit_dialog_for_section(section_for_input_fields) + + boolean_project_custom_field.move_to_bottom + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + overview_page.within_edit_dialog_for_section(section_for_input_fields) do + fields = page.all('.op-project-custom-field-input-container') + + expect(fields[0].text).to include('String field') + expect(fields[1].text).to include('Integer field') + expect(fields[2].text).to include('Float field') + expect(fields[3].text).to include('Date field') + expect(fields[4].text).to include('Text field') + expect(fields[5].text).to include('Boolean field') + end end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/overview_page.rb b/spec/features/projects/project_custom_fields/overview_page/overview_page.rb index d023fd74b2c7..85b3aa23f8cc 100644 --- a/spec/features/projects/project_custom_fields/overview_page/overview_page.rb +++ b/spec/features/projects/project_custom_fields/overview_page/overview_page.rb @@ -53,4 +53,22 @@ def within_custom_field_section_container(section, &) def within_custom_field_container(custom_field, &) within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) end + + def open_edit_dialog_for_section(section) + within_async_loaded_sidebar do + within_custom_field_section_container(section) do + page.find("[data-qa-selector='project-custom-field-section-edit-button']").click + end + end + end + + def close_edit_dialog_for_section(section) + within("modal-dialog#edit-project-attributes-dialog-#{section.id}") do + page.find(".close-button").click + end + end + + def within_edit_dialog_for_section(section, &) + within("modal-dialog#edit-project-attributes-dialog-#{section.id}", &) + end end diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb index 9053b5b014b8..0033eefa9cbe 100644 --- a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -174,4 +174,31 @@ field end + + let!(:input_fields) do + [ + boolean_project_custom_field, + string_project_custom_field, + integer_project_custom_field, + float_project_custom_field, + date_project_custom_field, + text_project_custom_field + ] + end + + let!(:select_fields) do + [ + list_project_custom_field, + version_project_custom_field, + user_project_custom_field + ] + end + + let!(:multi_select_fields) do + [ + multi_list_project_custom_field, + multi_version_project_custom_field, + multi_user_project_custom_field + ] + end end diff --git a/spec/features/projects/project_custom_fields/overview_page/show_spec.rb b/spec/features/projects/project_custom_fields/overview_page/show_spec.rb index f41ab4d3ded2..3895e3048f72 100644 --- a/spec/features/projects/project_custom_fields/overview_page/show_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/show_spec.rb @@ -44,7 +44,7 @@ overview_page.visit_page within '.op-grid-page' do - expect(page).not_to have_css('#project-attributes-sidebar') + expect(page).to have_no_css('#project-attributes-sidebar') end end end @@ -100,7 +100,7 @@ overview_page.visit_page overview_page.within_async_loaded_sidebar do - expect(page).not_to have_text 'String field enabled for other project' + expect(page).to have_no_text 'String field enabled for other project' end end end From cca82ec32d203a386f03a2c1dfe9ddbf13ba32f7 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 18 Jan 2024 12:56:25 +0100 Subject: [PATCH 040/218] added specs for project custom field edit dialog, refactored form input implementation, fixed minor bugs --- app/models/custom_field.rb | 1 + .../open_project/forms/autocompleter.html.erb | 2 +- .../open_project/forms/autocompleter.rb | 3 +- .../forms/dsl/autocompleter_input.rb | 7 +- .../sections/edit_dialog_component.html.erb | 2 +- .../sections/show_component.html.erb | 2 +- .../base/autocomplete/multi_value_input.rb | 61 +- .../base/autocomplete/single_value_input.rb | 67 ++ .../{base.rb => base/input.rb} | 17 +- .../forms/project/custom_value_form/bool.rb | 10 +- .../forms/project/custom_value_form/date.rb | 2 +- .../forms/project/custom_value_form/float.rb | 2 +- .../forms/project/custom_value_form/int.rb | 2 +- .../custom_value_form/multi_select_list.rb | 46 +- .../multi_user_select_list.rb | 59 +- .../multi_version_select_list.rb | 42 +- .../custom_value_form/single_select_list.rb | 24 +- .../single_user_select_list.rb | 48 +- .../single_version_select_list.rb | 23 +- .../forms/project/custom_value_form/string.rb | 2 +- .../forms/project/custom_value_form/text.rb | 2 +- .../overview_page/dialog_spec.rb | 694 ++++++++++++++++++ .../overview_page/edit_spec.rb | 163 ---- .../overview_page/shared_context.rb | 24 +- .../{show_spec.rb => sidebar_spec.rb} | 22 +- .../project_custom_fields/edit_dialog.rb | 479 ++++++++++++ .../primerized/autocomplete_field.rb | 66 ++ .../form_fields/primerized/form_field.rb | 13 + spec/support/pages/projects/show.rb | 29 + 29 files changed, 1516 insertions(+), 398 deletions(-) rename spec/features/projects/project_custom_fields/overview_page/overview_page.rb => modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb (51%) create mode 100644 modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb rename modules/overviews/app/forms/project/custom_value_form/{base.rb => base/input.rb} (86%) create mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb delete mode 100644 spec/features/projects/project_custom_fields/overview_page/edit_spec.rb rename spec/features/projects/project_custom_fields/overview_page/{show_spec.rb => sidebar_spec.rb} (98%) create mode 100644 spec/support/components/projects/project_custom_fields/edit_dialog.rb create mode 100644 spec/support/form_fields/primerized/autocomplete_field.rb create mode 100644 spec/support/form_fields/primerized/form_field.rb diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 2aa2e7109ae8..ef07583c9300 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -236,6 +236,7 @@ def self.filter def attribute_name(format = nil) return "customField#{id}" if format == :camel_case + return "custom-field-#{id}" if format == :kebab_case "custom_field_#{id}" end diff --git a/lib/primer/open_project/forms/autocompleter.html.erb b/lib/primer/open_project/forms/autocompleter.html.erb index bcd50431ed4f..3464bd8a99f0 100644 --- a/lib/primer/open_project/forms/autocompleter.html.erb +++ b/lib/primer/open_project/forms/autocompleter.html.erb @@ -1,4 +1,4 @@ -<%= render(FormControl.new(input: @input)) do %> +<%= render(FormControl.new(input: @input, data: @wrapper_data_attributes)) do %> <% if decorated_select? %> <%= render partial: '/augmented/autocomplete_select_decoration', locals: { diff --git a/lib/primer/open_project/forms/autocompleter.rb b/lib/primer/open_project/forms/autocompleter.rb index 51aedf8b1577..3767fd2b0714 100644 --- a/lib/primer/open_project/forms/autocompleter.rb +++ b/lib/primer/open_project/forms/autocompleter.rb @@ -9,10 +9,11 @@ class Autocompleter < Primer::Forms::BaseComponent delegate :builder, :form, :select_options, to: :@input - def initialize(input:, autocomplete_options:) + def initialize(input:, autocomplete_options:, wrapper_data_attributes: {}) super() @input = input @autocomplete_options = autocomplete_options + @wrapper_data_attributes = wrapper_data_attributes end def decorated_select? diff --git a/lib/primer/open_project/forms/dsl/autocompleter_input.rb b/lib/primer/open_project/forms/dsl/autocompleter_input.rb index 706037c19422..ea4355547802 100644 --- a/lib/primer/open_project/forms/dsl/autocompleter_input.rb +++ b/lib/primer/open_project/forms/dsl/autocompleter_input.rb @@ -5,7 +5,7 @@ module OpenProject module Forms module Dsl class AutocompleterInput < Primer::Forms::Dsl::Input - attr_reader :name, :label, :autocomplete_options, :select_options + attr_reader :name, :label, :autocomplete_options, :select_options, :wrapper_data_attributes class Option attr_reader :label, :value, :selected @@ -25,10 +25,11 @@ def to_h end end - def initialize(name:, label:, autocomplete_options:, **system_arguments) + def initialize(name:, label:, autocomplete_options:, wrapper_data_attributes: {}, **system_arguments) @name = name @label = label @autocomplete_options = autocomplete_options + @wrapper_data_attributes = wrapper_data_attributes @select_options = [] super(**system_arguments) @@ -41,7 +42,7 @@ def option(**args) end def to_component - Autocompleter.new(input: self, autocomplete_options:) + Autocompleter.new(input: self, autocomplete_options:, wrapper_data_attributes:) end def type diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index a2e06e95b846..14ba839a6470 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -1,7 +1,7 @@ <%= content_tag("turbo-frame", id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}-frame") do - component_wrapper do + component_wrapper(data: { qa_selector: 'async-dialog-content' }) do primer_form_with( model: @project, method: :put, diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 44012a99acbe..92fa94b6168c 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -14,7 +14,7 @@ id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}", src: project_attribute_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), size: :medium_portrait, - title: "#{t('label_edit')} #{ @project_custom_field_section.name }", + title: @project_custom_field_section.name, button_icon: :pencil, button_attributes: { scheme: :invisible, data: { qa_selector: "project-custom-field-section-edit-button" diff --git a/spec/features/projects/project_custom_fields/overview_page/overview_page.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb similarity index 51% rename from spec/features/projects/project_custom_fields/overview_page/overview_page.rb rename to modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb index 85b3aa23f8cc..05caa715f6d4 100644 --- a/spec/features/projects/project_custom_fields/overview_page/overview_page.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb @@ -25,50 +25,53 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -class OverviewPage - include Rails.application.routes.url_helpers - include Capybara::DSL - include Capybara::RSpecMatchers - include RSpec::Matchers - def initialize(project) +class Project::CustomValueForm::Base::Autocomplete::MultiValueInput < Project::CustomValueForm::Base::Input + def initialize(custom_field:, custom_field_values:, project:) + @custom_field = custom_field + @custom_field_values = custom_field_values @project = project end - def visit_page - visit project_path(@project.id) + def base_config + super.merge( + { + autocomplete_options: + }, + invalid: invalid?, + validation_message:, + wrapper_data_attributes: { + 'qa-field-name': qa_field_name + } + ) end - def within_async_loaded_sidebar - within '#project-attributes-sidebar' do - expect(page).to have_css("[data-qa-selector='project-attributes-sidebar-async-content']") - yield - end + def autocomplete_options + { + multiple: true, + decorated:, + inputId: id, + inputName: name + } end - def within_custom_field_section_container(section, &) - within("[data-qa-selector='project-custom-field-section-#{section.id}']", &) + def decorated + raise NotImplementedError end - def within_custom_field_container(custom_field, &) - within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) + def name + "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" end - def open_edit_dialog_for_section(section) - within_async_loaded_sidebar do - within_custom_field_section_container(section) do - page.find("[data-qa-selector='project-custom-field-section-edit-button']").click - end - end + def value + nil end - def close_edit_dialog_for_section(section) - within("modal-dialog#edit-project-attributes-dialog-#{section.id}") do - page.find(".close-button").click - end + def invalid? + @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } end - def within_edit_dialog_for_section(section, &) - within("modal-dialog#edit-project-attributes-dialog-#{section.id}", &) + def validation_message + @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb new file mode 100644 index 000000000000..2e1d1ed887f0 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb @@ -0,0 +1,67 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Project::CustomValueForm::Base::Autocomplete::SingleValueInput < Project::CustomValueForm::Base::Input + def base_config + super.merge( + { + autocomplete_options: + }, + invalid: invalid?, + validation_message:, + wrapper_data_attributes: { + 'qa-field-name': qa_field_name + } + ) + end + + def autocomplete_options + { + multiple: false, + decorated:, + inputId: id, + inputName: name + } + end + + def decorated + raise NotImplementedError + end + + def value + nil + end + + def invalid? + @custom_field_value.errors.any? + end + + def validation_message + @custom_field_value.errors.full_messages.join(', ') if invalid? + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/base.rb b/modules/overviews/app/forms/project/custom_value_form/base/input.rb similarity index 86% rename from modules/overviews/app/forms/project/custom_value_form/base.rb rename to modules/overviews/app/forms/project/custom_value_form/base/input.rb index f01ba20e5715..eb6cf8e4646b 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/input.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Base < ApplicationForm +class Project::CustomValueForm::Base::Input < ApplicationForm def initialize(custom_field:, custom_field_value:, project:) @custom_field = custom_field @custom_field_value = custom_field_value @@ -41,10 +41,11 @@ def base_config scope_id_to_model: false, placeholder: @custom_field.name, label: @custom_field.name, - value: @custom_field_value.value, + value:, required: @custom_field.is_required?, - invalid: @custom_field_value.errors.any?, - validation_message: @custom_field_value.errors.any? ? @custom_field_value.errors.full_messages&.join(" ") : nil + data: { + 'qa-field-name': qa_field_name + } } end @@ -59,4 +60,12 @@ def name def id name.gsub(/[\[\]]/, "_") end + + def value + @custom_field_value.value || @custom_field.default_value + end + + def qa_field_name + @custom_field.attribute_name(:kebab_case) + end end diff --git a/modules/overviews/app/forms/project/custom_value_form/bool.rb b/modules/overviews/app/forms/project/custom_value_form/bool.rb index 7be79995201d..9b7087fd8931 100644 --- a/modules/overviews/app/forms/project/custom_value_form/bool.rb +++ b/modules/overviews/app/forms/project/custom_value_form/bool.rb @@ -26,16 +26,16 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Bool < Project::CustomValueForm::Base +class Project::CustomValueForm::Bool < Project::CustomValueForm::Base::Input form do |custom_value_form| custom_value_form.check_box(**base_config) end def base_config super.merge({ - value: "1", - unchecked_value: "0", - checked: @custom_field_value&.typed_value == true - }) + value: "1", + unchecked_value: "0", + checked: @custom_field_value&.typed_value == true || @custom_field.default_value == true + }) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/date.rb b/modules/overviews/app/forms/project/custom_value_form/date.rb index 15f40f2fa593..c0a925aa70c2 100644 --- a/modules/overviews/app/forms/project/custom_value_form/date.rb +++ b/modules/overviews/app/forms/project/custom_value_form/date.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Date < Project::CustomValueForm::Base +class Project::CustomValueForm::Date < Project::CustomValueForm::Base::Input form do |custom_value_form| custom_value_form.text_field(**base_config) end diff --git a/modules/overviews/app/forms/project/custom_value_form/float.rb b/modules/overviews/app/forms/project/custom_value_form/float.rb index f68e6e550b9a..a4ddbf6a7f07 100644 --- a/modules/overviews/app/forms/project/custom_value_form/float.rb +++ b/modules/overviews/app/forms/project/custom_value_form/float.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Float < Project::CustomValueForm::Base +class Project::CustomValueForm::Float < Project::CustomValueForm::Base::Input form do |custom_value_form| custom_value_form.text_field(**base_config) end diff --git a/modules/overviews/app/forms/project/custom_value_form/int.rb b/modules/overviews/app/forms/project/custom_value_form/int.rb index 903937c2981e..0e336eb44dfe 100644 --- a/modules/overviews/app/forms/project/custom_value_form/int.rb +++ b/modules/overviews/app/forms/project/custom_value_form/int.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Int < Project::CustomValueForm::Base +class Project::CustomValueForm::Int < Project::CustomValueForm::Base::Input form do |custom_value_form| custom_value_form.text_field(**base_config) end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb index 70b75b193528..90efc15d2599 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb @@ -26,19 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::MultiSelectList < Project::CustomValueForm::Base - def initialize(custom_field:, custom_field_values:, project:) - @custom_field = custom_field - @custom_field_values = custom_field_values - @project = project - end - +class Project::CustomValueForm::MultiSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput form do |custom_value_form| custom_value_form.autocompleter(**base_config) do |list| @custom_field.custom_options.each do |custom_option| list.option( label: custom_option.value, value: custom_option.id, - selected: @custom_field_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) + selected: selected?(custom_option) ) end end @@ -46,35 +40,17 @@ def initialize(custom_field:, custom_field_values:, project:) private - def base_config - { - name:, - scope_name_to_model: false, - scope_id_to_model: false, # autocompleter does not respect scope_id_to_model = false - placeholder: @custom_field.name, - label: @custom_field.name, - required: @custom_field.is_required?, - include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected - autocomplete_options: { - multiple: true, - decorated: true, - inputId: id, - inputName: name - }, - invalid: invalid?, - validation_message: - } - end - - def name - "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" + def decorated + true end - def invalid? - @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } - end + def selected?(custom_option) + cf_values = @custom_field_values.reject { |custom_field_value| custom_field_value.id.nil? } - def validation_message - @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? + if cf_values.any? + cf_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) + else + custom_option.default_value? + end end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb index 42c8433f31c9..579f2fed1d28 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb @@ -26,48 +26,39 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::MultiUserSelectList < Project::CustomValueForm::Base - def initialize(custom_field:, custom_field_values:, project:) - @custom_field = custom_field - @custom_field_values = custom_field_values - @project = project - end - +class Project::CustomValueForm::MultiUserSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput form do |custom_value_form| custom_value_form.autocompleter(**base_config) end private - def base_config - { - name:, - scope_name_to_model: false, - scope_id_to_model: false, # autocompleter does not respect scope_id_to_model = false - placeholder: @custom_field.name, - label: @custom_field.name, - required: @custom_field.is_required?, - autocomplete_options: { - multiple: true, - # decorated: true, - inputId: id, - placeholder: "Search for users", - resource: 'principals', - filters: [{ name: 'type', operator: '=', values: ['User'] }, - { name: 'member', operator: '=', values: ['1'] }], - searchKey: 'any_name_attribute', - inputName: name, - inputValue: input_value - }, - invalid: invalid?, - validation_message: - } + def decorated + false + end + + def autocomplete_options + super.merge({ + placeholder: "Search for users", + resource: 'principals', + filters:, + searchKey: 'any_name_attribute', + inputValue: input_value + }) end def name "project[multi_user_custom_field_values_attributes][#{@custom_field.id}][comma_seperated_values][]" end + def filters + [ + { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, + { name: 'member', operator: '=', values: [@project.id.to_s] }, + { name: 'status', operator: '!', values: [User.statuses["locked"].to_s] } + ] + end + def input_value "?#{input_values_filter}" end @@ -79,12 +70,4 @@ def input_values_filter filters = [user_filter, id_filter] URI.encode_www_form("filters" => filters.to_json) end - - def invalid? - @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } - end - - def validation_message - @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? - end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb index 62b05cbc0fb1..ec773aecbc1b 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb @@ -26,19 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::MultiVersionSelectList < Project::CustomValueForm::Base - def initialize(custom_field:, custom_field_values:, project:) - @custom_field = custom_field - @custom_field_values = custom_field_values - @project = project - end - +class Project::CustomValueForm::MultiVersionSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput form do |custom_value_form| custom_value_form.autocompleter(**base_config) do |list| @project.versions.each do |version| list.option( label: version.name, value: version.id, - selected: @custom_field_values.pluck(:value).map { |value| value&.to_i }.include?(version.id) + selected: selected?(version) ) end end @@ -46,35 +40,11 @@ def initialize(custom_field:, custom_field_values:, project:) private - def base_config - { - name:, - scope_name_to_model: false, - scope_id_to_model: false, # autocompleter does not respect scope_id_to_model = false - placeholder: @custom_field.name, - label: @custom_field.name, - required: @custom_field.is_required?, - include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected - autocomplete_options: { - multiple: true, - decorated: true, - inputId: id, - inputName: name - }, - invalid: invalid?, - validation_message: - } - end - - def name - "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" - end - - def invalid? - @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } + def decorated + true end - def validation_message - @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? + def selected?(version) + @custom_field_values.pluck(:value).map { |value| value&.to_i }.include?(version.id) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb index e05ff913db01..d8d70d731821 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb @@ -26,29 +26,25 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::SingleSelectList < Project::CustomValueForm::Base +class Project::CustomValueForm::SingleSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput form do |custom_value_form| custom_value_form.autocompleter(**base_config) do |list| @custom_field_value.custom_field.custom_options.each do |custom_option| list.option( label: custom_option.value, value: custom_option.id, - selected: custom_option.id == @custom_field_value.value&.to_i + selected: selected?(custom_option) ) end end end - def base_config - super.merge( - { - include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected - autocomplete_options: { - multiple: false, - decorated: true, - inputId: id, - inputName: name - } - } - ) + private + + def decorated + true + end + + def selected?(custom_option) + custom_option.id == @custom_field_value.value&.to_i || custom_option.id == @custom_field.default_value&.to_i end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb index f410dae1e5f3..ba293ba04bce 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb @@ -26,48 +26,44 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::SingleUserSelectList < Project::CustomValueForm::Base +class Project::CustomValueForm::SingleUserSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput form do |custom_value_form| custom_value_form.autocompleter(**base_config) end - def base_config + private + + def decorated + false + end + + def autocomplete_options super.merge({ - autocomplete_options: { - inputId: id, - placeholder: "Search for a user", - resource: 'principals', - filters: [{ name: 'type', operator: '=', values: ['User'] }, - { name: 'member', operator: '=', values: ['1'] }], - searchKey: 'any_name_attribute', - inputName: name, - inputValue: input_value - # focusDirectly: true, - # appendTo: 'body', - # disabled: @disabled - }, - invalid: invalid?, - validation_message: + placeholder: "Search for a user", + resource: 'principals', + filters:, + searchKey: 'any_name_attribute', + inputValue: input_value }) end + def filters + [ + { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, + { name: 'member', operator: '=', values: [@project.id.to_s] }, + { name: 'status', operator: '!', values: [User.statuses["locked"].to_s] } + ] + end + def input_value "?#{input_values_filter}" end def input_values_filter - user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } + user_filter = { "type" => { "operator" => "=", "values" => ['User', 'Group', 'PlaceholderUser'] } } id_filter = { "id" => { "operator" => "=", "values" => @custom_field_value.value } } filters = [user_filter, id_filter] URI.encode_www_form("filters" => filters.to_json) end - - def invalid? - @custom_field_value.errors.any? - end - - def validation_message - @custom_field_value.errors.full_messages.join(', ') if invalid? - end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb index 5437fa2f82f4..72f1d1a1b4ac 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb @@ -26,30 +26,25 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::SingleVersionSelectList < Project::CustomValueForm::Base +class Project::CustomValueForm::SingleVersionSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput form do |custom_value_form| custom_value_form.autocompleter(**base_config) do |list| @project.versions.each do |version| list.option( label: version.name, value: version.id, - selected: version.id == @custom_field_value.value&.to_i + selected: selected?(version) ) end end end - def base_config - super.merge( - { - include_blank: @custom_field.is_required? ? false : '_blank', # autocompleter does not send '_blank' as value when no option is selected - autocomplete_options: { - multiple: false, - decorated: true, - inputId: id, - inputName: name, - } - } - ) + private + + def decorated + true end + def selected?(version) + version.id == @custom_field_value.value&.to_i + end end diff --git a/modules/overviews/app/forms/project/custom_value_form/string.rb b/modules/overviews/app/forms/project/custom_value_form/string.rb index e301c10da3cd..5b535eb950ac 100644 --- a/modules/overviews/app/forms/project/custom_value_form/string.rb +++ b/modules/overviews/app/forms/project/custom_value_form/string.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::String < Project::CustomValueForm::Base +class Project::CustomValueForm::String < Project::CustomValueForm::Base::Input form do |custom_value_form| custom_value_form.text_field(**base_config) end diff --git a/modules/overviews/app/forms/project/custom_value_form/text.rb b/modules/overviews/app/forms/project/custom_value_form/text.rb index 6c66afda1acf..5c4758b3f743 100644 --- a/modules/overviews/app/forms/project/custom_value_form/text.rb +++ b/modules/overviews/app/forms/project/custom_value_form/text.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Text < Project::CustomValueForm::Base +class Project::CustomValueForm::Text < Project::CustomValueForm::Base::Input form do |custom_value_form| # TODO: rich_text_area not working yet # Uncaught DOMException: Failed to execute 'querySelector' on 'Element': '#project_project[new_custom_field_values_attributes][xyz][value]' is not a valid selector. diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb new file mode 100644 index 000000000000..685dff43762e --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb @@ -0,0 +1,694 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative 'shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + login_as admin + end + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + before do + overview_page.visit_page + end + + describe 'with sufficient permissions' do + describe 'enables editing of project custom field values via dialog' do + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } + + it 'opens a dialog showing inputs for project custom fields of a specific section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.expect_open + end + + it 'renders the dialog body asynchronically' do + expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible) + end + + it 'can be closed via close icon or cancel button' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.close_via_icon + + dialog.expect_closed + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.close_via_button + + dialog.expect_closed + end + + it 'shows only the project custom fields of the specific section within the dialog' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if input_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_select_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_multi_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if multi_select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + end + + it 'shows the inputs in the correct order defined by the position of project custom field in a section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers + + expect(containers[0].text).to include('Boolean field') + expect(containers[1].text).to include('String field') + expect(containers[2].text).to include('Integer field') + expect(containers[3].text).to include('Float field') + expect(containers[4].text).to include('Date field') + expect(containers[5].text).to include('Text field') + end + + boolean_project_custom_field.move_to_bottom + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers + + expect(containers[0].text).to include('String field') + expect(containers[1].text).to include('Integer field') + expect(containers[2].text).to include('Float field') + expect(containers[3].text).to include('Date field') + expect(containers[4].text).to include('Text field') + expect(containers[5].text).to include('Boolean field') + end + end + + describe 'with correct inital values' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field checkbox' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + if expected_initial_value + expect(page).to have_checked_field(custom_field.name) + else + expect(page).to have_no_checked_field(custom_field.name) + end + end + end + + it 'is unchecked if no value and no default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) + end + end + + it 'shows default value if no value is given' do + custom_field.custom_values.destroy_all + + custom_field.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_checked_field(custom_field.name) + end + + custom_field.update!(default_value: false) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) + end + end + end + + shared_examples 'a custom field input' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_initial_value) + end + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_blank_value) + end + end + + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: default_value) + end + end + end + + describe 'with boolean CF' do + let(:custom_field) { boolean_project_custom_field } + let(:default_value) { false } + let(:expected_blank_value) { false } + let(:expected_initial_value) { true } + + it_behaves_like 'a custom field checkbox' + end + + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:default_value) { 'Default value' } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 'Foo' } + + it_behaves_like 'a custom field input' + end + + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:default_value) { 789 } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 123 } + + it_behaves_like 'a custom field input' + end + + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:default_value) { 789.123 } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 123.456 } + + it_behaves_like 'a custom field input' + end + + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:default_value) { Date.new(2026, 1, 1) } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { Date.new(2024, 1, 1) } + + it_behaves_like 'a custom field input' + end + + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:default_value) { 'Default value' } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { "Lorem\nipsum" } + + it_behaves_like 'a custom field input' + end + end + + describe 'with single select fields' do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a autocomplete single select field' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(expected_initial_value) + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.expect_blank + end + + it 'filters the list based on the input' do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end + + it 'enables the user to select a single value from a list' do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + field.select_option(second_option) + + field.expect_selected(second_option) + + field.search(third_option) + field.select_option(third_option) + + field.expect_selected(third_option) + field.expect_not_selected(second_option) + end + + it 'clears the input if clicked on the clear button' do + overview_page.open_edit_dialog_for_section(section) + + field.clear + + field.expect_blank + end + end + + describe 'with single select list CF' do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { custom_field.custom_options.first.value } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } + + it_behaves_like 'a autocomplete single select field' + + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + + custom_field.custom_options.first.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(custom_field.custom_options.first.value) + end + end + + describe 'with single version select list CF' do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { first_version.name } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } + + it_behaves_like 'a autocomplete single select field' + + describe 'with correct version scoping' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end + + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Version 1') + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end + end + end + + describe 'with single user select list CF' do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { member_in_project.name } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } + + it_behaves_like 'a autocomplete single select field' + + describe 'with correct user scoping' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + + it 'shows only users that are members of the project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Member 1') + + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) + end + end + + describe 'with support for user groups' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end + + it 'shows only groups that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Group 1') + + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end + end + + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder User', + member_with_roles: { project => reader_role }) + end + + it 'shows the placeholder user' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Placeholder User') + + field.expect_option(placeholder_user.name) + end + end + end + end + + describe 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a autocomplete multi select field' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(*expected_initial_value) + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.expect_blank + end + + it 'filters the list based on the input' do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end + + it 'allows to select multiple values' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.expect_selected(second_option) + field.expect_selected(third_option) + end + + it 'allows to remove selected values' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.deselect_option(third_option) + + field.expect_selected(second_option) + field.expect_not_selected(third_option) + end + + it 'allows to remove all selected values at once' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.clear + + field.expect_not_selected(second_option) + field.expect_not_selected(third_option) + end + end + + describe 'with multi select list CF' do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } + + it_behaves_like 'a autocomplete multi select field' + + it 'shows the default value if no value is given' do + multi_list_project_custom_field.custom_values.destroy_all + + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(multi_list_project_custom_field.custom_options.first.value) + field.expect_selected(multi_list_project_custom_field.custom_options.second.value) + end + end + + describe 'with multi version select list CF' do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [first_version.name, second_version.name] } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } + + it_behaves_like 'a autocomplete multi select field' + + describe 'with correct version scoping' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end + + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Version 1') + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end + end + end + + describe 'with multi user select list CF' do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } + + it_behaves_like 'a autocomplete multi select field' + + describe 'with correct user scoping' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + + it 'shows only users that are members of the project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Member 1') + + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) + end + end + + describe 'with support for user groups' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: 'Group 2 in project', + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end + + it 'shows only groups that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Group 1') + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end + + it 'enables to select multiple user groups' do + overview_page.open_edit_dialog_for_section(section) + + field.select_option('Group 1 in project') + field.select_option('Group 2 in project') + + field.expect_selected('Group 1 in project') + field.expect_selected('Group 2 in project') + end + end + + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: 'Another placeholder User', + member_with_roles: { project => reader_role }) + end + let!(:placeholder_user_in_other_project) do + create(:placeholder_user, name: 'Placeholder user in other project', + member_with_roles: { other_project => reader_role }) + end + + it 'shows only placeholder users from this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Placeholder User') + + field.expect_option(placeholder_user.name) + field.expect_option(another_placeholder_user.name) + field.expect_no_option(placeholder_user_in_other_project.name) + end + + it 'enables to select multiple placeholder users' do + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) + + field.expect_selected(placeholder_user.name) + field.expect_selected(another_placeholder_user.name) + end + end + end + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb b/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb deleted file mode 100644 index 414424a812dc..000000000000 --- a/spec/features/projects/project_custom_fields/overview_page/edit_spec.rb +++ /dev/null @@ -1,163 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2023 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' -require_relative 'shared_context' -require_relative 'overview_page' - -RSpec.describe 'Edit project custom fields on project overview page', :js, :with_cuprite do - include_context 'with seeded projects, members and project custom fields' - - let(:overview_page) { OverviewPage.new(project) } - - before do - login_as admin - end - - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - describe 'with sufficient permissions' do - describe 'enables editing of project custom field values via dialog' do - it 'opens a dialog showing inputs for project custom fields of a specific section' do - overview_page.visit_page - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - expect(page).to have_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") - end - - it 'renders the dialog body asynchronically' do - overview_page.visit_page - - expect(page).to have_no_css('#project-custom-fields-sections-edit-dialog-component', visible: :all) - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - expect(page).to have_css('#project-custom-fields-sections-edit-dialog-component', visible: :visible) - end - - it 'can be closed via close icon or cancel button' do - overview_page.visit_page - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - within("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") do - page.find(".close-button").click - end - - expect(page).to have_no_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - within("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") do - click_link_or_button 'Cancel' - end - - expect(page).to have_no_css("modal-dialog#edit-project-attributes-dialog-#{section_for_input_fields.id}") - end - - it 'shows only the project custom fields of the specific section within the dialog' do - overview_page.visit_page - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - overview_page.within_edit_dialog_for_section(section_for_input_fields) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if input_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end - end - end - - overview_page.close_edit_dialog_for_section(section_for_input_fields) - - overview_page.open_edit_dialog_for_section(section_for_select_fields) - - overview_page.within_edit_dialog_for_section(section_for_select_fields) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if select_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end - end - end - - overview_page.close_edit_dialog_for_section(section_for_select_fields) - - overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) - - overview_page.within_edit_dialog_for_section(section_for_multi_select_fields) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if multi_select_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end - end - end - end - - it 'shows the inputs in the correct order defined by the position of project custom field in a section' do - overview_page.visit_page - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - overview_page.within_edit_dialog_for_section(section_for_input_fields) do - fields = page.all('.op-project-custom-field-input-container') - - expect(fields[0].text).to include('Boolean field') - expect(fields[1].text).to include('String field') - expect(fields[2].text).to include('Integer field') - expect(fields[3].text).to include('Float field') - expect(fields[4].text).to include('Date field') - expect(fields[5].text).to include('Text field') - end - - overview_page.close_edit_dialog_for_section(section_for_input_fields) - - boolean_project_custom_field.move_to_bottom - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - overview_page.within_edit_dialog_for_section(section_for_input_fields) do - fields = page.all('.op-project-custom-field-input-container') - - expect(fields[0].text).to include('String field') - expect(fields[1].text).to include('Integer field') - expect(fields[2].text).to include('Float field') - expect(fields[3].text).to include('Date field') - expect(fields[4].text).to include('Text field') - expect(fields[5].text).to include('Boolean field') - end - end - end - end - end -end diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb index 0033eefa9cbe..f38bcfa26b12 100644 --- a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -30,8 +30,13 @@ let(:project) { create(:project, name: 'Foo project', identifier: 'foo-project') } let(:other_project) { create(:project, name: 'Bar project', identifier: 'bar-project') } - let(:first_version) { create(:version, name: 'Version 1', project:) } - let(:second_version) { create(:version, name: 'Version 2', project:) } + let!(:first_version) { create(:version, name: 'Version 1', project:) } + let!(:second_version) { create(:version, name: 'Version 2', project:) } + let!(:third_version) { create(:version, name: 'Version 3', project:) } + + shared_let(:reader_role) do + create(:project_role, permissions: %i[view_work_packages]) + end let!(:admin) do create(:admin) @@ -41,18 +46,21 @@ create(:user, firstname: 'Member 1', lastname: 'In Project', - member_with_permissions: { project => %w[ - view_work_packages - ] }) + member_with_roles: { project => reader_role }) end let!(:another_member_in_project) do create(:user, firstname: 'Member 2', lastname: 'In Project', - member_with_permissions: { project => %w[ - view_work_packages - ] }) + member_with_roles: { project => reader_role }) + end + + let!(:one_more_member_in_project) do + create(:user, + firstname: 'Member 3', + lastname: 'In Project', + member_with_roles: { project => reader_role }) end let!(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } diff --git a/spec/features/projects/project_custom_fields/overview_page/show_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb similarity index 98% rename from spec/features/projects/project_custom_fields/overview_page/show_spec.rb rename to spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 3895e3048f72..03c0b5667e06 100644 --- a/spec/features/projects/project_custom_fields/overview_page/show_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -720,6 +720,14 @@ end end end + + describe 'with support for user groups' do + # TODO + end + + describe 'with support for user placeholders' do + # TODO + end end describe 'with multi list CF' do @@ -833,18 +841,4 @@ end end end - - # TODO: share examples in order to reduce code duplication - # shared_examples 'a project custom field' do - # it 'shows the correct value for the project custom field if given' do - # overview_page.visit_page - - # overview_page.within_async_loaded_sidebar do - # overview_page.within_custom_field_container(custom_field) do - # expect(page).to have_text custom_field.name - # expect(page).to have_text custom_field.formatted_value - # end - # end - # end - # end end diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb new file mode 100644 index 000000000000..1bb07c2c3fd8 --- /dev/null +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -0,0 +1,479 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 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 'support/components/common/modal' +require 'support/components/autocompleter/ng_select_autocomplete_helpers' + +module Components + module Projects + module ProjectCustomFields + class EditDialog < Components::Common::Modal + include Components::Autocompleter::NgSelectAutocompleteHelpers + + attr_reader :project, :project_custom_field_section, :title + + def initialize(project, project_custom_field_section) + super() + + @project = project + @project_custom_field_section = project_custom_field_section + @title = @project_custom_field_section.name + end + + def dialog_css_selector + "modal-dialog#edit-project-attributes-dialog-#{@project_custom_field_section.id}" + end + + def async_content_container_css_selector + "#{dialog_css_selector} [data-qa-selector='async-dialog-content']" + end + + def within_dialog(&) + within(dialog_css_selector, &) + end + + def within_async_content(close_after_yield: false, &) + within(async_content_container_css_selector, &) + close if close_after_yield + end + + def close + within_dialog do + page.find(".close-button").click + end + end + alias_method :close_via_icon, :close + + def close_via_button + within(dialog_css_selector) do + click_link_or_button 'Cancel' + end + end + + def expect_open + expect(page).to have_css(dialog_css_selector) + end + + def expect_closed + expect(page).to have_no_css(dialog_css_selector) + end + + def expect_async_content_loaded + expect(page).to have_css(async_content_container_css_selector) + end + + ### + + def input_containers + page.all('.op-project-custom-field-input-container') + end + + def within_custom_field_input_container(custom_field, &) + # wrapping in `within_async_content` to make sure the container is properly loaded + within_async_content do + within("[data-qa-selector='project-custom-field-input-container-#{custom_field.id}']", &) + end + end + end + end + end +end + +# def select_shares(*principals) +# within shares_list do +# principals.each do |principal| +# check principal.name +# end +# end +# end + +# def deselect_shares(*principals) +# within shares_list do +# principals.each do |principal| +# uncheck principal.name +# end +# end +# end + +# def expect_not_selectable(*principals) +# principals.each do |principal| +# within user_row(principal) do +# expect(page).to have_no_field(principal.name) +# end +# end +# end + +# def toggle_select_all +# within shares_header do +# if page.find_field('toggle_all').checked? +# uncheck 'toggle_all' +# else +# check 'toggle_all' +# end +# end +# end + +# def expect_selected(*principals) +# within shares_list do +# principals.each do |principal| +# expect(page).to have_checked_field(principal.name) +# end +# end +# end + +# def expect_deselected(*principals) +# within shares_list do +# principals.each do |principal| +# expect(page).to have_unchecked_field(principal.name) +# end +# end +# end + +# def expect_selected_count_of(count) +# expect(shares_header) +# .to have_text("#{count} selected") +# end + +# def expect_select_all_available +# expect(shares_header) +# .to have_field('toggle_all') +# end + +# def expect_select_all_not_available +# expect(shares_header) +# .to have_no_field('toggle_all', wait: 0) +# end + +# def expect_select_all_toggled +# within shares_header do +# expect(page).to have_checked_field('toggle_all') +# end +# end + +# def expect_select_all_untoggled +# within shares_header do +# expect(page).to have_unchecked_field('toggle_all') +# end +# end + +# def expect_bulk_actions_available +# within shares_header do +# expect(page).to have_button 'Remove' +# expect(page).to have_test_selector('op-share-wp-bulk-update-role') +# end +# end + +# def expect_bulk_actions_not_available +# within shares_header do +# expect(page).to have_no_button('Remove', wait: 0) +# expect(page).not_to have_test_selector('op-share-wp-bulk-update-role', wait: 0) +# end +# end + +# def bulk_remove +# within shares_header do +# click_button 'Remove' +# end +# end + +# def bulk_update(role_name) +# within shares_header do +# find('[data-test-selector="op-share-wp-bulk-update-role"]').click + +# find('.ActionListContent', text: role_name).click +# end +# end + +# def expect_bulk_update_label(label_text) +# within shares_header do +# expect(page) +# .to have_css('[data-test-selector="op-share-wp-bulk-update-role"] .Button-label', +# text: label_text) +# if label_text == 'Mixed' +# %w[View Comment Edit].each do |permission_name| +# within bulk_update_form(permission_name) do +# expect(page) +# .to have_css(unchecked_permission, visible: :all) +# end +# end +# else +# within bulk_update_form(label_text) do +# expect(page) +# .to have_css(checked_permission, visible: :all) +# end +# end +# end +# end + +# def bulk_update_form(permission_name) +# find("[data-test-selector='op-share-wp-bulk-update-role-permission-#{permission_name}']", visible: :all) +# end + +# def checked_permission +# 'button[type=submit][aria-checked=true]' +# end + +# def unchecked_permission +# 'button[type=submit][aria-checked="false"]' +# end + +# def expect_blankslate +# within_modal do +# expect(page).to have_text(I18n.t('work_package.sharing.text_empty_state_description')) +# end +# end + +# def expect_empty_search_blankslate +# within_modal do +# expect(page).to have_text(I18n.t('work_package.sharing.text_empty_search_description')) +# end +# end + +# def invite_user(users, role_name) +# Array(users).each do |user| +# case user +# when String +# select_not_existing_user_option(user) +# when Principal +# select_existing_user(user) +# end +# end + +# select_invite_role(role_name) + +# within_modal do +# click_button 'Share' +# end +# end + +# alias_method :invite_users, :invite_user +# alias_method :invite_group, :invite_user + +# # Augments +invite_user+ by asserting that the modifications to the +# # share have reflected in the modal's UI and we're able to continue +# # with our spec without any waits or network related assertions. +# # +# # As a side benefit, it just keeps the spec file cleaner. +# def invite_user!(user, role_name) +# invite_user(user, role_name) +# expect_shared_with(user, role_name) +# end + +# def search_user(search_string) +# search_autocomplete page.find('[data-test-selector="op-share-wp-invite-autocomplete"]'), +# query: search_string, +# results_selector: 'body' +# end + +# def remove_user(user) +# within user_row(user) do +# click_button 'Remove' +# end +# end + +# def select_invite_role(role_name) +# within modal_element.find('[data-test-selector="op-share-wp-invite-role"]') do +# # Open the ActionMenu +# click_button 'View' + +# find('.ActionListContent', text: role_name).click +# end +# end + +# def change_role(user, role_name) +# within user_row(user) do +# find('[data-test-selector="op-share-wp-update-role"]').click + +# within '.ActionListWrap' do +# click_button role_name +# end +# end +# end + +# def filter(filter_name, value) +# within(shares_header) do +# retry_block do +# # The button's text changes dynamically based on the currently selected option +# # Hence the spec's readability is hindered by using something like +# # `click_button filter_name.capitalize` +# find("[data-test-selector='op-share-wp-filter-#{filter_name}-button']").click + +# # Open the ActionMenu +# find('.ActionListContent', text: value).click +# end +# end + +# wait_for_network_idle # Ensures filtering is done +# end + +# def close +# within_modal do +# page.find("[data-test-selector='op-share-wp-modal--close-icon']").click +# end +# end + +# def click_share +# within_modal do +# click_button 'Share' +# end +# end + +# def expect_shared_with(user, role_name = nil, position: nil, editable: true) +# within_modal do +# within shares_list do +# expect(page).to have_list_item(text: user.name, position:) +# within(:list_item, text: user.name, position:) do +# if role_name +# expect(page).to have_button(role_name), +# "Expected share with #{user.name.inspect} to have button #{role_name}." +# end +# unless editable +# expect(page).not_to have_button, +# "Expected share with #{user.name.inspect} not to be editable (expected no buttons)." +# end +# end +# end +# end +# end + +# def expect_not_shared_with(*principals) +# within shares_list do +# principals.each do |principal| +# expect(page) +# .to have_no_text(principal.name) +# end +# end +# end + +# def expect_shared_count_of(count) +# expect(shares_header) +# .to have_text(I18n.t('work_package.sharing.count', count:)) +# end + +# def expect_no_invite_option +# within_modal do +# expect(page) +# .to have_text(I18n.t('work_package.sharing.permissions.denied')) +# end +# end + +# def resend_invite(user) +# within user_row(user) do +# click_button I18n.t("work_package.sharing.user_details.resend_invite") +# end +# end + +# def expect_invite_resent(user) +# within user_row(user) do +# expect(page) +# .to have_text(I18n.t("work_package.sharing.user_details.invite_resent")) +# end +# end + +# def user_row(user) +# within_modal do +# find(:list_item, text: user.name) +# end +# end + +# def active_list +# modal_element +# .find('[data-test-selector="op-share-wp-active-list"]') +# end + +# def shares_header +# active_list.find('[data-test-selector="op-share-wp-header"]') +# end + +# def shares_counter +# shares_header.find('[data-test-selector="op-share-wp-active-count"]') +# end + +# def shares_list +# find_by_id('op-share-wp-active-shares') +# end + +# def select_existing_user(user) +# select_autocomplete page.find('[data-test-selector="op-share-wp-invite-autocomplete"]'), +# query: user.firstname, +# select_text: user.name, +# results_selector: 'body' +# end + +# def select_not_existing_user_option(email) +# select_autocomplete page.find('[data-test-selector="op-share-wp-invite-autocomplete"]'), +# query: email, +# select_text: "Send invite to\"#{email}\"", +# results_selector: 'body' +# end + +# def expect_upsale_banner +# within_modal do +# expect(page) +# .to have_text(I18n.t(:label_enterprise_addon)) +# end +# end + +# def expect_no_user_limit_warning +# within modal_element do +# expect(page) +# .to have_no_text(I18n.t('work_package.sharing.warning_user_limit_reached'), wait: 0) +# end +# end + +# def expect_user_limit_warning +# within modal_element do +# expect(page) +# .to have_text(I18n.t('work_package.sharing.warning_user_limit_reached')) +# end +# end + +# def expect_error_message(text) +# within modal_element do +# expect(page) +# .to have_css('[data-test-selector="op-share-wp-error-message"]', +# text:) +# end +# end + +# def expect_select_a_user_hint +# within modal_element do +# expect(page) +# .to have_text(I18n.t("work_package.sharing.warning_no_selected_user")) +# end +# end + +# def expect_no_select_a_user_hint +# within modal_element do +# expect(page) +# .to have_no_text(I18n.t("work_package.sharing.warning_no_selected_user"), wait: 0) +# end +# end +# end +# end +# end diff --git a/spec/support/form_fields/primerized/autocomplete_field.rb b/spec/support/form_fields/primerized/autocomplete_field.rb new file mode 100644 index 000000000000..ff62c7fcc86d --- /dev/null +++ b/spec/support/form_fields/primerized/autocomplete_field.rb @@ -0,0 +1,66 @@ +require_relative 'form_field' + +module FormFields + module Primerized + class AutocompleteField < FormField + ### actions + + def select_option(*values) + values.each do |val| + field_container.find('.ng-select-container').click + expect(page).to have_css('.ng-option', text: val, visible: :all) + page.find('.ng-option', text: val, visible: :all).click + sleep 0.25 # still required? + end + end + + def deselect_option(*values) + values.each do |val| + field_container.find('.ng-select-container').click + page.find('.ng-value', text: val, visible: :all).find('.ng-value-icon').click + sleep 0.25 # still required? + end + end + + def search(text) + field_container.find('.ng-select-container input').set text + end + + def clear + field_container.find('.ng-clear-wrapper', visible: :all).click + end + + ### expectations + + def expect_selected(*values) + values.each do |val| + expect(field_container).to have_css('.ng-value', text: val) + end + end + + def expect_not_selected(*values) + values.each do |val| + expect(field_container).to have_no_css('.ng-value', text: val) + end + end + + def expect_blank + expect(field_container).to have_css('.ng-value', count: 0) + end + + def expect_no_option(option) + expect(page) + .to have_no_css('.ng-option', text: option, visible: :all) + end + + def expect_option(option) + expect(page) + .to have_css('.ng-option', text: option, visible: :visible) + end + + def expect_visible + expect(field_container).to have_css('ng-select') + end + end + end +end diff --git a/spec/support/form_fields/primerized/form_field.rb b/spec/support/form_fields/primerized/form_field.rb new file mode 100644 index 000000000000..a97c67b7aefe --- /dev/null +++ b/spec/support/form_fields/primerized/form_field.rb @@ -0,0 +1,13 @@ +module FormFields + module Primerized + class FormField < FormFields::FormField + def property_name + if property.is_a? CustomField + property.attribute_name(:kebab_case) + else + property.to_s + end + end + end + end +end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 8d685e8c514f..a2c3cac9b147 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -50,6 +50,35 @@ def within_sidebar(&) def toast_type :rails end + + def visit_page + visit path + end + + def within_async_loaded_sidebar(&) + within '#project-attributes-sidebar' do + expect(page).to have_css("[data-qa-selector='project-attributes-sidebar-async-content']") + yield + end + end + + def within_custom_field_section_container(section, &) + within("[data-qa-selector='project-custom-field-section-#{section.id}']", &) + end + + def within_custom_field_container(custom_field, &) + within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) + end + + def open_edit_dialog_for_section(section) + within_async_loaded_sidebar do + within_custom_field_section_container(section) do + page.find("[data-qa-selector='project-custom-field-section-edit-button']").click + end + end + + expect(page).to have_css("[data-qa-selector='async-dialog-content']", wait: 5) + end end end end From d529ee818a5db6e6d5e8509ef7061f5b19000bdc Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 18 Jan 2024 17:34:51 +0100 Subject: [PATCH 041/218] hiding more parts of the new concept behind the feature flag --- app/controllers/custom_fields_controller.rb | 10 +++++++--- app/helpers/custom_fields_helper.rb | 21 +++++++++++++-------- app/views/custom_fields/_form.html.erb | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index 55c6898d45e9..c229492a5dbb 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -39,8 +39,10 @@ def index # loading wp cfs exclicity to allow for eager loading @custom_fields_by_type = CustomField.all .where.not(type: 'WorkPackageCustomField') - .where.not(type: 'ProjectCustomField') # ProjecCustomFields now managed in a different UI + # ProjectCustomFields now managed in a different UI + .tap { |query| query.where.not(type: 'ProjectCustomField') unless OpenProject::FeatureDecisions.project_attributes_active? } .group_by { |f| f.class.name } + @custom_fields_by_type['WorkPackageCustomField'] = WorkPackageCustomField.includes(:types).all @tab = params[:tab] || 'WorkPackageCustomField' @@ -77,8 +79,10 @@ def find_custom_field end def check_custom_field - if @custom_field.nil? || @custom_field.type == 'ProjectCustomField' - # ProjecCustomFields now managed in a different UI + # ProjecCustomFields now managed in a different UI + if + OpenProject::FeatureDecisions.project_attributes_active? && + (@custom_field.nil? || @custom_field.type == 'ProjectCustomField') flash[:error] = 'Invalid CF type' redirect_to action: :index end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 49cc14532a88..873052d234aa 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -28,7 +28,7 @@ module CustomFieldsHelper def custom_fields_tabs - [ + tabs = [ { name: 'WorkPackageCustomField', partial: 'custom_fields/tab', @@ -41,13 +41,13 @@ def custom_fields_tabs path: custom_fields_path(tab: :TimeEntryCustomField), label: :label_spent_time }, - # ProjecCustomFields now managed in a different UI - # { - # name: 'ProjectCustomField', - # partial: 'custom_fields/tab', - # path: custom_fields_path(tab: :ProjectCustomField), - # label: :label_project_plural - # }, + + { + name: 'ProjectCustomField', + partial: 'custom_fields/tab', + path: custom_fields_path(tab: :ProjectCustomField), + label: :label_project_plural + }, { name: 'VersionCustomField', partial: 'custom_fields/tab', @@ -67,6 +67,11 @@ def custom_fields_tabs label: :label_group_plural } ] + + # ProjecCustomFields now managed in a different UI + tabs.delete_if { |tab| tab[:name] == 'ProjectCustomField' && OpenProject::FeatureDecisions.project_attributes_active? } + + tabs end # Return custom field html tag corresponding to its format diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index c8d6cd1a10ac..c9033425b5fe 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.text_field :name, required: true, container_class: '-middle' %>
- <% if @custom_field.type == 'ProjectCustomField' %> + <% if @custom_field.type == 'ProjectCustomField' && OpenProject::FeatureDecisions.project_attributes_active? %>
<%= f.select :custom_field_section_id, ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, From 8ac7c64f90dd0584e8f9b2ce4f652862dfb40130 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 19 Jan 2024 12:17:34 +0100 Subject: [PATCH 042/218] refactored form implementations --- .../base/autocomplete/multi_value_input.rb | 20 +++--- .../base/autocomplete/single_value_input.rb | 26 +++---- .../base/autocomplete/user_query_utils.rb | 67 +++++++++++++++++++ .../project/custom_value_form/base/input.rb | 37 +++------- .../project/custom_value_form/base/utils.rb | 57 ++++++++++++++++ .../forms/project/custom_value_form/bool.rb | 4 +- .../forms/project/custom_value_form/date.rb | 4 +- .../forms/project/custom_value_form/float.rb | 4 +- .../forms/project/custom_value_form/int.rb | 4 +- .../custom_value_form/multi_select_list.rb | 4 +- .../multi_user_select_list.rb | 38 +++-------- .../multi_version_select_list.rb | 4 +- .../custom_value_form/single_select_list.rb | 4 +- .../single_user_select_list.rb | 34 ++-------- .../single_version_select_list.rb | 4 +- .../forms/project/custom_value_form/string.rb | 2 +- .../forms/project/custom_value_form/text.rb | 2 +- 17 files changed, 188 insertions(+), 127 deletions(-) create mode 100644 modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb create mode 100644 modules/overviews/app/forms/project/custom_value_form/base/utils.rb diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb index 05caa715f6d4..a9d165138eee 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb @@ -26,18 +26,18 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Base::Autocomplete::MultiValueInput < Project::CustomValueForm::Base::Input +class Project::CustomValueForm::Base::Autocomplete::MultiValueInput < ApplicationForm + include Project::CustomValueForm::Base::Utils + def initialize(custom_field:, custom_field_values:, project:) @custom_field = custom_field @custom_field_values = custom_field_values @project = project end - def base_config - super.merge( - { - autocomplete_options: - }, + def input_attributes + base_input_attributes.merge( + autocomplete_options:, invalid: invalid?, validation_message:, wrapper_data_attributes: { @@ -49,13 +49,13 @@ def base_config def autocomplete_options { multiple: true, - decorated:, + decorated: decorated?, inputId: id, inputName: name } end - def decorated + def decorated? raise NotImplementedError end @@ -63,10 +63,6 @@ def name "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" end - def value - nil - end - def invalid? @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb index 2e1d1ed887f0..83a1fe618287 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb @@ -26,12 +26,18 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Base::Autocomplete::SingleValueInput < Project::CustomValueForm::Base::Input - def base_config - super.merge( - { - autocomplete_options: - }, +class Project::CustomValueForm::Base::Autocomplete::SingleValueInput < ApplicationForm + include Project::CustomValueForm::Base::Utils + + def initialize(custom_field:, custom_field_value:, project:) + @custom_field = custom_field + @custom_field_value = custom_field_value + @project = project + end + + def input_attributes + base_input_attributes.merge( + autocomplete_options:, invalid: invalid?, validation_message:, wrapper_data_attributes: { @@ -43,20 +49,16 @@ def base_config def autocomplete_options { multiple: false, - decorated:, + decorated: decorated?, inputId: id, inputName: name } end - def decorated + def decorated? raise NotImplementedError end - def value - nil - end - def invalid? @custom_field_value.errors.any? end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb new file mode 100644 index 000000000000..99c16b8799f0 --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb @@ -0,0 +1,67 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Project::CustomValueForm::Base::Autocomplete::UserQueryUtils + def user_autocomplete_options + { + placeholder: I18n.t(:label_user_search), + resource:, + filters:, + searchKey: search_key, + inputValue: input_value + } + end + + def resource + 'principals' + end + + def search_key + 'any_name_attribute' + end + + def filters + [ + { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, + { name: 'member', operator: '=', values: [@project.id.to_s] }, + { name: 'status', operator: '!', values: [User.statuses["locked"].to_s] } + ] + end + + def input_value + "?#{input_values_filter}" + end + + def input_values_filter + user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } + id_filter = { "id" => { "operator" => "=", "values" => init_user_ids } } + + filters = [user_filter, id_filter] + URI.encode_www_form("filters" => filters.to_json) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/input.rb b/modules/overviews/app/forms/project/custom_value_form/base/input.rb index eb6cf8e4646b..b5b9809ee29e 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/input.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/input.rb @@ -27,45 +27,24 @@ #++ class Project::CustomValueForm::Base::Input < ApplicationForm + include Project::CustomValueForm::Base::Utils + def initialize(custom_field:, custom_field_value:, project:) @custom_field = custom_field @custom_field_value = custom_field_value @project = project end - def base_config - { - name:, - id:, - scope_name_to_model: false, - scope_id_to_model: false, - placeholder: @custom_field.name, - label: @custom_field.name, - value:, - required: @custom_field.is_required?, - data: { - 'qa-field-name': qa_field_name + def input_attributes + base_input_attributes.merge( + { + data: { 'qa-field-name': qa_field_name }, + value: } - } - end - - def name - if @custom_field_value.new_record? - "project[new_custom_field_values_attributes][#{@custom_field_value.custom_field_id}][value]" - else - "project[custom_field_values_attributes][#{@custom_field_value.id}][value]" - end - end - - def id - name.gsub(/[\[\]]/, "_") + ) end def value @custom_field_value.value || @custom_field.default_value end - - def qa_field_name - @custom_field.attribute_name(:kebab_case) - end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/utils.rb b/modules/overviews/app/forms/project/custom_value_form/base/utils.rb new file mode 100644 index 000000000000..16ab8e4b3a6f --- /dev/null +++ b/modules/overviews/app/forms/project/custom_value_form/base/utils.rb @@ -0,0 +1,57 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Project::CustomValueForm::Base::Utils + def base_input_attributes + { + name:, + id:, + scope_name_to_model: false, + scope_id_to_model: false, + placeholder: @custom_field.name, + label: @custom_field.name, + required: @custom_field.is_required? + } + end + + def id + name.gsub(/[\[\]]/, "_") + end + + def name + if @custom_field_value.new_record? + "project[new_custom_field_values_attributes][#{@custom_field_value.custom_field_id}][value]" + else + "project[custom_field_values_attributes][#{@custom_field_value.id}][value]" + end + end + + def qa_field_name + @custom_field.attribute_name(:kebab_case) + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/bool.rb b/modules/overviews/app/forms/project/custom_value_form/bool.rb index 9b7087fd8931..29cd0f79dc08 100644 --- a/modules/overviews/app/forms/project/custom_value_form/bool.rb +++ b/modules/overviews/app/forms/project/custom_value_form/bool.rb @@ -28,10 +28,10 @@ class Project::CustomValueForm::Bool < Project::CustomValueForm::Base::Input form do |custom_value_form| - custom_value_form.check_box(**base_config) + custom_value_form.check_box(**input_attributes) end - def base_config + def input_attributes super.merge({ value: "1", unchecked_value: "0", diff --git a/modules/overviews/app/forms/project/custom_value_form/date.rb b/modules/overviews/app/forms/project/custom_value_form/date.rb index c0a925aa70c2..00b9cc8cc639 100644 --- a/modules/overviews/app/forms/project/custom_value_form/date.rb +++ b/modules/overviews/app/forms/project/custom_value_form/date.rb @@ -28,10 +28,10 @@ class Project::CustomValueForm::Date < Project::CustomValueForm::Base::Input form do |custom_value_form| - custom_value_form.text_field(**base_config) + custom_value_form.text_field(**input_attributes) end - def base_config + def input_attributes super.merge({ type: "date" }) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/float.rb b/modules/overviews/app/forms/project/custom_value_form/float.rb index a4ddbf6a7f07..2d12a23b91d4 100644 --- a/modules/overviews/app/forms/project/custom_value_form/float.rb +++ b/modules/overviews/app/forms/project/custom_value_form/float.rb @@ -28,10 +28,10 @@ class Project::CustomValueForm::Float < Project::CustomValueForm::Base::Input form do |custom_value_form| - custom_value_form.text_field(**base_config) + custom_value_form.text_field(**input_attributes) end - def base_config + def input_attributes super.merge({ type: "number", step: :any }) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/int.rb b/modules/overviews/app/forms/project/custom_value_form/int.rb index 0e336eb44dfe..2f6590c86756 100644 --- a/modules/overviews/app/forms/project/custom_value_form/int.rb +++ b/modules/overviews/app/forms/project/custom_value_form/int.rb @@ -28,10 +28,10 @@ class Project::CustomValueForm::Int < Project::CustomValueForm::Base::Input form do |custom_value_form| - custom_value_form.text_field(**base_config) + custom_value_form.text_field(**input_attributes) end - def base_config + def input_attributes super.merge({ type: "number", step: 1 }) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb index 90efc15d2599..ca9e26ecd5ab 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb @@ -28,7 +28,7 @@ class Project::CustomValueForm::MultiSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput form do |custom_value_form| - custom_value_form.autocompleter(**base_config) do |list| + custom_value_form.autocompleter(**input_attributes) do |list| @custom_field.custom_options.each do |custom_option| list.option( label: custom_option.value, value: custom_option.id, @@ -40,7 +40,7 @@ class Project::CustomValueForm::MultiSelectList < Project::CustomValueForm::Base private - def decorated + def decorated? true end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb index 579f2fed1d28..360c22880ffd 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb @@ -27,47 +27,27 @@ #++ class Project::CustomValueForm::MultiUserSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput + include Project::CustomValueForm::Base::Autocomplete::UserQueryUtils + form do |custom_value_form| - custom_value_form.autocompleter(**base_config) + custom_value_form.autocompleter(**input_attributes) end private - def decorated + def decorated? false end def autocomplete_options - super.merge({ - placeholder: "Search for users", - resource: 'principals', - filters:, - searchKey: 'any_name_attribute', - inputValue: input_value - }) - end - - def name - "project[multi_user_custom_field_values_attributes][#{@custom_field.id}][comma_seperated_values][]" + super.merge(user_autocomplete_options) end - def filters - [ - { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, - { name: 'member', operator: '=', values: [@project.id.to_s] }, - { name: 'status', operator: '!', values: [User.statuses["locked"].to_s] } - ] + def init_user_ids + @custom_field_values.map(&:value) end - def input_value - "?#{input_values_filter}" - end - - def input_values_filter - user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } - id_filter = { "id" => { "operator" => "=", "values" => @custom_field_values.map(&:value) } } - - filters = [user_filter, id_filter] - URI.encode_www_form("filters" => filters.to_json) + def name + "project[multi_user_custom_field_values_attributes][#{@custom_field.id}][comma_seperated_values][]" end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb index ec773aecbc1b..716ff1749d0d 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb @@ -28,7 +28,7 @@ class Project::CustomValueForm::MultiVersionSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput form do |custom_value_form| - custom_value_form.autocompleter(**base_config) do |list| + custom_value_form.autocompleter(**input_attributes) do |list| @project.versions.each do |version| list.option( label: version.name, value: version.id, @@ -40,7 +40,7 @@ class Project::CustomValueForm::MultiVersionSelectList < Project::CustomValueFor private - def decorated + def decorated? true end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb index d8d70d731821..74d553784f4a 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb @@ -28,7 +28,7 @@ class Project::CustomValueForm::SingleSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput form do |custom_value_form| - custom_value_form.autocompleter(**base_config) do |list| + custom_value_form.autocompleter(**input_attributes) do |list| @custom_field_value.custom_field.custom_options.each do |custom_option| list.option( label: custom_option.value, value: custom_option.id, @@ -40,7 +40,7 @@ class Project::CustomValueForm::SingleSelectList < Project::CustomValueForm::Bas private - def decorated + def decorated? true end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb index ba293ba04bce..f7261a8ee0dd 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb @@ -27,43 +27,23 @@ #++ class Project::CustomValueForm::SingleUserSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput + include Project::CustomValueForm::Base::Autocomplete::UserQueryUtils + form do |custom_value_form| - custom_value_form.autocompleter(**base_config) + custom_value_form.autocompleter(**input_attributes) end private - def decorated + def decorated? false end def autocomplete_options - super.merge({ - placeholder: "Search for a user", - resource: 'principals', - filters:, - searchKey: 'any_name_attribute', - inputValue: input_value - }) - end - - def filters - [ - { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, - { name: 'member', operator: '=', values: [@project.id.to_s] }, - { name: 'status', operator: '!', values: [User.statuses["locked"].to_s] } - ] + super.merge(user_autocomplete_options) end - def input_value - "?#{input_values_filter}" - end - - def input_values_filter - user_filter = { "type" => { "operator" => "=", "values" => ['User', 'Group', 'PlaceholderUser'] } } - id_filter = { "id" => { "operator" => "=", "values" => @custom_field_value.value } } - - filters = [user_filter, id_filter] - URI.encode_www_form("filters" => filters.to_json) + def init_user_ids + [@custom_field_value.value] end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb b/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb index 72f1d1a1b4ac..769995409660 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb +++ b/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb @@ -28,7 +28,7 @@ class Project::CustomValueForm::SingleVersionSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput form do |custom_value_form| - custom_value_form.autocompleter(**base_config) do |list| + custom_value_form.autocompleter(**input_attributes) do |list| @project.versions.each do |version| list.option( label: version.name, value: version.id, @@ -40,7 +40,7 @@ class Project::CustomValueForm::SingleVersionSelectList < Project::CustomValueFo private - def decorated + def decorated? true end diff --git a/modules/overviews/app/forms/project/custom_value_form/string.rb b/modules/overviews/app/forms/project/custom_value_form/string.rb index 5b535eb950ac..25dfe7faa1e4 100644 --- a/modules/overviews/app/forms/project/custom_value_form/string.rb +++ b/modules/overviews/app/forms/project/custom_value_form/string.rb @@ -28,6 +28,6 @@ class Project::CustomValueForm::String < Project::CustomValueForm::Base::Input form do |custom_value_form| - custom_value_form.text_field(**base_config) + custom_value_form.text_field(**input_attributes) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/text.rb b/modules/overviews/app/forms/project/custom_value_form/text.rb index 5c4758b3f743..2e06d9f8a8e8 100644 --- a/modules/overviews/app/forms/project/custom_value_form/text.rb +++ b/modules/overviews/app/forms/project/custom_value_form/text.rb @@ -33,6 +33,6 @@ class Project::CustomValueForm::Text < Project::CustomValueForm::Base::Input # --> rich_text_area is not using the configured id, which is not scoped to model via base_config # --> ids with '[' ']' are not valid selectors # using simple text area for now - custom_value_form.text_area(**base_config) + custom_value_form.text_area(**input_attributes) end end From 82c2209a97aebfb7db89e147e1470b23f1d7e36d Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 19 Jan 2024 14:29:35 +0100 Subject: [PATCH 043/218] disable editing for project members without editing permission --- .../sections/show_component.html.erb | 2 +- .../sections/show_component.rb | 4 + .../overview_page/dialog_spec.rb | 32 +- .../overview_page/shared_context.rb | 15 + .../project_custom_fields/edit_dialog.rb | 374 ------------------ 5 files changed, 45 insertions(+), 382 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 92fa94b6168c..3a573264c343 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -20,7 +20,7 @@ qa_selector: "project-custom-field-section-edit-button" } } )) - end + end if allowed_to_edit? end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb index 45a49107b7d5..e3352cad3e92 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb @@ -45,6 +45,10 @@ def initialize(project:, project_custom_field_section:, project_custom_fields:) private + def allowed_to_edit? + User.current.allowed_in_project?(:edit_project, @project) + end + def eager_load_project_custom_field_values # TODO: move to service @eager_loaded_project_custom_field_values = CustomValue diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb index 685dff43762e..977d7fddef11 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb @@ -34,16 +34,34 @@ let(:overview_page) { Pages::Projects::Show.new(project) } - before do - login_as admin - end - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - before do - overview_page.visit_page + describe 'with insufficient permissions' do + # TODO: turboframe sidebar request needs to be covered by a controller spec checking for 403 + # TODO: async dialog content request needs to be covered by a controller spec checking for 403 + before do + login_as member_without_project_edit_permissions + overview_page.visit_page + end + + it 'does not show the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-edit-button']") + end + end end describe 'with sufficient permissions' do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + it 'shows the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_css("[data-qa-selector='project-custom-field-section-edit-button']", count: 3) + end + end + describe 'enables editing of project custom field values via dialog' do let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } @@ -147,7 +165,7 @@ end end - describe 'with correct inital values' do + describe 'with correct initialization and input behaviour' do describe 'with input fields' do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb index f38bcfa26b12..5af4fb30494c 100644 --- a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -38,6 +38,10 @@ create(:project_role, permissions: %i[view_work_packages]) end + shared_let(:edit_role) do + create(:project_role, permissions: %i[view_work_packages edit_project]) + end + let!(:admin) do create(:admin) end @@ -63,6 +67,17 @@ member_with_roles: { project => reader_role }) end + let!(:member_with_project_edit_permissions) do + create(:user, + firstname: 'Member', + lastname: 'With Project Edit Permissions', + member_with_roles: { project => edit_role }) + end + + let!(:member_without_project_edit_permissions) do + member_in_project + end + let!(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } let!(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } let!(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb index 1bb07c2c3fd8..3b47651eea45 100644 --- a/spec/support/components/projects/project_custom_fields/edit_dialog.rb +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -103,377 +103,3 @@ def within_custom_field_input_container(custom_field, &) end end end - -# def select_shares(*principals) -# within shares_list do -# principals.each do |principal| -# check principal.name -# end -# end -# end - -# def deselect_shares(*principals) -# within shares_list do -# principals.each do |principal| -# uncheck principal.name -# end -# end -# end - -# def expect_not_selectable(*principals) -# principals.each do |principal| -# within user_row(principal) do -# expect(page).to have_no_field(principal.name) -# end -# end -# end - -# def toggle_select_all -# within shares_header do -# if page.find_field('toggle_all').checked? -# uncheck 'toggle_all' -# else -# check 'toggle_all' -# end -# end -# end - -# def expect_selected(*principals) -# within shares_list do -# principals.each do |principal| -# expect(page).to have_checked_field(principal.name) -# end -# end -# end - -# def expect_deselected(*principals) -# within shares_list do -# principals.each do |principal| -# expect(page).to have_unchecked_field(principal.name) -# end -# end -# end - -# def expect_selected_count_of(count) -# expect(shares_header) -# .to have_text("#{count} selected") -# end - -# def expect_select_all_available -# expect(shares_header) -# .to have_field('toggle_all') -# end - -# def expect_select_all_not_available -# expect(shares_header) -# .to have_no_field('toggle_all', wait: 0) -# end - -# def expect_select_all_toggled -# within shares_header do -# expect(page).to have_checked_field('toggle_all') -# end -# end - -# def expect_select_all_untoggled -# within shares_header do -# expect(page).to have_unchecked_field('toggle_all') -# end -# end - -# def expect_bulk_actions_available -# within shares_header do -# expect(page).to have_button 'Remove' -# expect(page).to have_test_selector('op-share-wp-bulk-update-role') -# end -# end - -# def expect_bulk_actions_not_available -# within shares_header do -# expect(page).to have_no_button('Remove', wait: 0) -# expect(page).not_to have_test_selector('op-share-wp-bulk-update-role', wait: 0) -# end -# end - -# def bulk_remove -# within shares_header do -# click_button 'Remove' -# end -# end - -# def bulk_update(role_name) -# within shares_header do -# find('[data-test-selector="op-share-wp-bulk-update-role"]').click - -# find('.ActionListContent', text: role_name).click -# end -# end - -# def expect_bulk_update_label(label_text) -# within shares_header do -# expect(page) -# .to have_css('[data-test-selector="op-share-wp-bulk-update-role"] .Button-label', -# text: label_text) -# if label_text == 'Mixed' -# %w[View Comment Edit].each do |permission_name| -# within bulk_update_form(permission_name) do -# expect(page) -# .to have_css(unchecked_permission, visible: :all) -# end -# end -# else -# within bulk_update_form(label_text) do -# expect(page) -# .to have_css(checked_permission, visible: :all) -# end -# end -# end -# end - -# def bulk_update_form(permission_name) -# find("[data-test-selector='op-share-wp-bulk-update-role-permission-#{permission_name}']", visible: :all) -# end - -# def checked_permission -# 'button[type=submit][aria-checked=true]' -# end - -# def unchecked_permission -# 'button[type=submit][aria-checked="false"]' -# end - -# def expect_blankslate -# within_modal do -# expect(page).to have_text(I18n.t('work_package.sharing.text_empty_state_description')) -# end -# end - -# def expect_empty_search_blankslate -# within_modal do -# expect(page).to have_text(I18n.t('work_package.sharing.text_empty_search_description')) -# end -# end - -# def invite_user(users, role_name) -# Array(users).each do |user| -# case user -# when String -# select_not_existing_user_option(user) -# when Principal -# select_existing_user(user) -# end -# end - -# select_invite_role(role_name) - -# within_modal do -# click_button 'Share' -# end -# end - -# alias_method :invite_users, :invite_user -# alias_method :invite_group, :invite_user - -# # Augments +invite_user+ by asserting that the modifications to the -# # share have reflected in the modal's UI and we're able to continue -# # with our spec without any waits or network related assertions. -# # -# # As a side benefit, it just keeps the spec file cleaner. -# def invite_user!(user, role_name) -# invite_user(user, role_name) -# expect_shared_with(user, role_name) -# end - -# def search_user(search_string) -# search_autocomplete page.find('[data-test-selector="op-share-wp-invite-autocomplete"]'), -# query: search_string, -# results_selector: 'body' -# end - -# def remove_user(user) -# within user_row(user) do -# click_button 'Remove' -# end -# end - -# def select_invite_role(role_name) -# within modal_element.find('[data-test-selector="op-share-wp-invite-role"]') do -# # Open the ActionMenu -# click_button 'View' - -# find('.ActionListContent', text: role_name).click -# end -# end - -# def change_role(user, role_name) -# within user_row(user) do -# find('[data-test-selector="op-share-wp-update-role"]').click - -# within '.ActionListWrap' do -# click_button role_name -# end -# end -# end - -# def filter(filter_name, value) -# within(shares_header) do -# retry_block do -# # The button's text changes dynamically based on the currently selected option -# # Hence the spec's readability is hindered by using something like -# # `click_button filter_name.capitalize` -# find("[data-test-selector='op-share-wp-filter-#{filter_name}-button']").click - -# # Open the ActionMenu -# find('.ActionListContent', text: value).click -# end -# end - -# wait_for_network_idle # Ensures filtering is done -# end - -# def close -# within_modal do -# page.find("[data-test-selector='op-share-wp-modal--close-icon']").click -# end -# end - -# def click_share -# within_modal do -# click_button 'Share' -# end -# end - -# def expect_shared_with(user, role_name = nil, position: nil, editable: true) -# within_modal do -# within shares_list do -# expect(page).to have_list_item(text: user.name, position:) -# within(:list_item, text: user.name, position:) do -# if role_name -# expect(page).to have_button(role_name), -# "Expected share with #{user.name.inspect} to have button #{role_name}." -# end -# unless editable -# expect(page).not_to have_button, -# "Expected share with #{user.name.inspect} not to be editable (expected no buttons)." -# end -# end -# end -# end -# end - -# def expect_not_shared_with(*principals) -# within shares_list do -# principals.each do |principal| -# expect(page) -# .to have_no_text(principal.name) -# end -# end -# end - -# def expect_shared_count_of(count) -# expect(shares_header) -# .to have_text(I18n.t('work_package.sharing.count', count:)) -# end - -# def expect_no_invite_option -# within_modal do -# expect(page) -# .to have_text(I18n.t('work_package.sharing.permissions.denied')) -# end -# end - -# def resend_invite(user) -# within user_row(user) do -# click_button I18n.t("work_package.sharing.user_details.resend_invite") -# end -# end - -# def expect_invite_resent(user) -# within user_row(user) do -# expect(page) -# .to have_text(I18n.t("work_package.sharing.user_details.invite_resent")) -# end -# end - -# def user_row(user) -# within_modal do -# find(:list_item, text: user.name) -# end -# end - -# def active_list -# modal_element -# .find('[data-test-selector="op-share-wp-active-list"]') -# end - -# def shares_header -# active_list.find('[data-test-selector="op-share-wp-header"]') -# end - -# def shares_counter -# shares_header.find('[data-test-selector="op-share-wp-active-count"]') -# end - -# def shares_list -# find_by_id('op-share-wp-active-shares') -# end - -# def select_existing_user(user) -# select_autocomplete page.find('[data-test-selector="op-share-wp-invite-autocomplete"]'), -# query: user.firstname, -# select_text: user.name, -# results_selector: 'body' -# end - -# def select_not_existing_user_option(email) -# select_autocomplete page.find('[data-test-selector="op-share-wp-invite-autocomplete"]'), -# query: email, -# select_text: "Send invite to\"#{email}\"", -# results_selector: 'body' -# end - -# def expect_upsale_banner -# within_modal do -# expect(page) -# .to have_text(I18n.t(:label_enterprise_addon)) -# end -# end - -# def expect_no_user_limit_warning -# within modal_element do -# expect(page) -# .to have_no_text(I18n.t('work_package.sharing.warning_user_limit_reached'), wait: 0) -# end -# end - -# def expect_user_limit_warning -# within modal_element do -# expect(page) -# .to have_text(I18n.t('work_package.sharing.warning_user_limit_reached')) -# end -# end - -# def expect_error_message(text) -# within modal_element do -# expect(page) -# .to have_css('[data-test-selector="op-share-wp-error-message"]', -# text:) -# end -# end - -# def expect_select_a_user_hint -# within modal_element do -# expect(page) -# .to have_text(I18n.t("work_package.sharing.warning_no_selected_user")) -# end -# end - -# def expect_no_select_a_user_hint -# within modal_element do -# expect(page) -# .to have_no_text(I18n.t("work_package.sharing.warning_no_selected_user"), wait: 0) -# end -# end -# end -# end -# end From 83dc6e58acdc1adc66ff3089a824c9412cb25173 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 19 Jan 2024 16:06:08 +0100 Subject: [PATCH 044/218] started to add validation specs --- .../sections/edit_dialog_component.html.erb | 8 +- .../base/autocomplete/multi_value_input.rb | 2 - .../base/autocomplete/single_value_input.rb | 2 - .../project/custom_value_form/base/input.rb | 8 + .../project/custom_value_form/base/utils.rb | 4 +- .../overview_page/dialog_spec.rb | 169 ++++++++++++++++++ .../project_custom_fields/edit_dialog.rb | 6 + .../form_fields/primerized/input_field.rb | 28 +++ 8 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 spec/support/form_fields/primerized/input_field.rb diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index 14ba839a6470..83cb4ae632b6 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -26,7 +26,13 @@ )) do t("button_cancel") end - footer_collection.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do + footer_collection.with_component(Primer::ButtonComponent.new( + scheme: :primary, + type: :submit, + data: { + qa_selector: 'save-project-attributes-button' + } + )) do t("button_save") end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb index a9d165138eee..9a97e4040d3d 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb @@ -38,8 +38,6 @@ def initialize(custom_field:, custom_field_values:, project:) def input_attributes base_input_attributes.merge( autocomplete_options:, - invalid: invalid?, - validation_message:, wrapper_data_attributes: { 'qa-field-name': qa_field_name } diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb index 83a1fe618287..f681142c79f8 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb @@ -38,8 +38,6 @@ def initialize(custom_field:, custom_field_value:, project:) def input_attributes base_input_attributes.merge( autocomplete_options:, - invalid: invalid?, - validation_message:, wrapper_data_attributes: { 'qa-field-name': qa_field_name } diff --git a/modules/overviews/app/forms/project/custom_value_form/base/input.rb b/modules/overviews/app/forms/project/custom_value_form/base/input.rb index b5b9809ee29e..3dc03b52b1ed 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/input.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/input.rb @@ -47,4 +47,12 @@ def input_attributes def value @custom_field_value.value || @custom_field.default_value end + + def invalid? + @custom_field_value.errors.any? + end + + def validation_message + @custom_field_value.errors.full_messages.join(', ') if invalid? + end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/utils.rb b/modules/overviews/app/forms/project/custom_value_form/base/utils.rb index 16ab8e4b3a6f..df2c9fb39896 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/utils.rb +++ b/modules/overviews/app/forms/project/custom_value_form/base/utils.rb @@ -35,7 +35,9 @@ def base_input_attributes scope_id_to_model: false, placeholder: @custom_field.name, label: @custom_field.name, - required: @custom_field.is_required? + required: @custom_field.is_required?, + invalid: invalid?, + validation_message: } end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb index 977d7fddef11..1e54e7685aea 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb @@ -706,6 +706,175 @@ end end end + + describe 'with correct validation behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field input' do + it 'shows an error if the value is invalid' do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit(wait_until_done: true) + + field.expect_error(I18n.t('activerecord.errors.messages.blank')) + end + end + + # boolean CFs can not be validated + + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 3) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'Foooo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 3, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'Fo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) + end + + it 'shows an error if the value does not match the regex' do + custom_field.update!(regexp: '^[A-Z]+$') + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'foo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.invalid')) + end + end + + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 2) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '111') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 2)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 2, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '1') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 2)) + end + end + + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 4) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '1111.1') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 4)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 4, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '1.1') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 4)) + end + end + + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + end + + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 3) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'Foo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 3, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'Fo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) + end + end + end + end + + describe 'with correct updating behaviour' do + # TODO + end end end end diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb index 3b47651eea45..297a69f1966f 100644 --- a/spec/support/components/projects/project_custom_fields/edit_dialog.rb +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -75,6 +75,12 @@ def close_via_button end end + def submit + within(dialog_css_selector) do + page.find("[data-qa-selector='save-project-attributes-button']").click + end + end + def expect_open expect(page).to have_css(dialog_css_selector) end diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb new file mode 100644 index 000000000000..c5dd77f3b776 --- /dev/null +++ b/spec/support/form_fields/primerized/input_field.rb @@ -0,0 +1,28 @@ +require_relative 'form_field' + +module FormFields + module Primerized + class InputField < FormField + delegate :fill_in, to: :input_element + + def field_container + page.find(selector).first(:xpath, ".//..").first(:xpath, ".//..") + end + + def input_element + field_container + end + + def send_keys(*) + input_element.send_keys(*) + end + + # expectations + + def expect_error(string = nil) + expect(page).to have_css("#{selector}[invalid='true']") + expect(field_container).to have_content(string) if string + end + end + end +end From 8f64885f8e299cec8636e81be532d6df7e35572c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 25 Jan 2024 08:41:08 +0100 Subject: [PATCH 045/218] Linting drag & drop controller --- .../generic-drag-and-drop.controller.ts | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts index 81872c2e8aec..cbe9cd9c06bd 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts @@ -32,13 +32,18 @@ import * as Turbo from '@hotwired/turbo'; import { Controller } from '@hotwired/stimulus'; import { Drake } from 'dragula'; import { debugLog } from 'core-app/shared/helpers/debug_output'; -import { dropRight, get } from 'lodash'; + +interface TargetConfig { + container:Element; + allowedDragType:string|null; + targetId:string|null; +} export default class extends Controller { drake:Drake|undefined; - targetConfigs:Object[]; + targetConfigs:TargetConfig[]; - containerTargets:Element[] + containerTargets:Element[]; connect() { this.initDrake(); @@ -48,20 +53,22 @@ export default class extends Controller { this.setContainerTargetsAndConfigs(); // reinit drake if it already exists - if(this.drake){ + if (this.drake) { this.drake.destroy(); } - this.drake = dragula(this.containerTargets, + this.drake = dragula( + this.containerTargets, { moves: (_el, _source, handle, _sibling) => !!handle?.classList.contains('octicon-grabber'), - accepts: (el?: Element | null, target?: Element | null, source?: Element | null, sibling?: Element | null) => this.accepts(el!, target!, source!, sibling!), - revertOnSpill: true // enable reverting of elements if they are dropped outside of a valid target + accepts: (el:Element, target:Element, source:Element, sibling:Element) => this.accepts(el, target, source, sibling), + revertOnSpill: true, // enable reverting of elements if they are dropped outside of a valid target }, ) - // eslint-disable-next-line @typescript-eslint/no-misused-promises - .on('drag', this.drag.bind(this)) - .on('drop', this.drop.bind(this)); + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .on('drag', this.drag.bind(this)) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .on('drop', this.drop.bind(this)); // Setup autoscroll void window.OpenProject.getPluginContext().then((pluginContext) => { @@ -87,31 +94,27 @@ export default class extends Controller { } } - setContainerTargetsAndConfigs(): void { + setContainerTargetsAndConfigs():void { const rawTargets = Array.from( - this.element.querySelectorAll('[data-is-drag-and-drop-target="true"]') - ) as Element[]; + this.element.querySelectorAll('[data-is-drag-and-drop-target="true"]'), + ); this.targetConfigs = []; - let processedTargets: Element[] = []; - - rawTargets.forEach((target: Element) => { - let targetConfig: { - container: Element, - allowedDragType: string|null, - targetId: string|null - } = { + let processedTargets:Element[] = []; + + rawTargets.forEach((target:Element) => { + const targetConfig:TargetConfig = { container: target, allowedDragType: target.getAttribute('data-target-allowed-drag-type'), - targetId: target.getAttribute('data-target-id') + targetId: target.getAttribute('data-target-id'), }; // if the target has a container accessor, use that as the container instead of the element itself // we need this e.g. in Primer's boderbox component as we cannot add required data attributes to the ul element there const containerAccessor = target.getAttribute('data-target-container-accessor'); - if(containerAccessor){ - target = target.querySelector(containerAccessor) as Element - targetConfig.container = target + if (containerAccessor) { + target = target.querySelector(containerAccessor) as Element; + targetConfig.container = target; } // we need to save the targetConfigs separately as we need to pass the pure container elements to drake @@ -125,8 +128,8 @@ export default class extends Controller { } accepts(el:Element, target:Element, _source:Element|null, _sibling:Element|null) { - const targetConfig: any = this.targetConfigs.find((config: any) => config.container == target); - const acceptedDragType = targetConfig?.allowedDragType as string | undefined; + const targetConfig = this.targetConfigs.find((config) => config.container === target); + const acceptedDragType = targetConfig?.allowedDragType as string|undefined; const draggableType = el.getAttribute('data-draggable-type'); @@ -138,34 +141,34 @@ export default class extends Controller { return true; } - drag(el:Element, _source:Element|null) { + drag(_:Element, _source:Element|null) { // discover new target containers if they have been added to the DOM via Turbo streams this.reInitDrakeContainers(); } - async drop(el:Element, target:Element, _source:Element|null, sibling:Element|null) { + async drop(el:Element, target:Element, _source:Element|null, _sibling:Element|null) { const dropUrl = el.getAttribute('data-drop-url'); let targetPosition = Array.from(target.children).indexOf(el); - if(target.children.length > 0 && target.children[0].getAttribute('data-empty-list-item') == 'true'){ + if (target.children.length > 0 && target.children[0].getAttribute('data-empty-list-item') === 'true') { // if the target container is empty, a list item showing an empty message might be shown // this should not be counted as a list item // thus we need to subtract 1 from the target position - targetPosition--; + targetPosition -= 1; } - const targetConfig: any = this.targetConfigs.find((config: any) => config.container == target); - const targetId = targetConfig?.targetId as string | undefined; + const targetConfig = this.targetConfigs.find((config) => config.container === target); + const targetId = targetConfig?.targetId as string|undefined; const data = new FormData(); data.append('position', (targetPosition + 1).toString()); - if(targetId){ + if (targetId) { data.append('target_id', targetId.toString()); } - if(dropUrl){ + if (dropUrl) { const response = await fetch(dropUrl, { method: 'PUT', body: data, @@ -183,7 +186,7 @@ export default class extends Controller { Turbo.renderStreamMessage(text); // reinit drake containers as the DOM will be updated by Turbo streams // otherwise the DOM references in the Drake instance will be outdated - setTimeout(()=> this.reInitDrakeContainers(), 100); + setTimeout(() => this.reInitDrakeContainers(), 100); } } From 5721538fba2f0be267b9cdb37ea5b6c0d2c51d84 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 30 Jan 2024 14:12:13 +0700 Subject: [PATCH 046/218] WIP: refactoring of prototypical code as discussed --- app/forms/projects/custom_fields/form.rb | 115 ++++++++ .../base/autocomplete/multi_value_input.rb | 16 +- .../base/autocomplete/single_value_input.rb | 12 +- .../base/autocomplete/user_query_utils.rb | 3 +- .../custom_fields/inputs}/base/input.rb | 16 +- .../custom_fields/inputs}/base/utils.rb | 34 ++- .../projects/custom_fields/inputs}/bool.rb | 2 +- .../projects/custom_fields/inputs}/date.rb | 2 +- .../projects/custom_fields/inputs}/float.rb | 2 +- .../projects/custom_fields/inputs}/int.rb | 2 +- .../inputs}/multi_select_list.rb | 4 +- .../inputs}/multi_user_select_list.rb | 11 +- .../inputs}/multi_version_select_list.rb | 4 +- .../inputs}/single_select_list.rb | 6 +- .../inputs}/single_user_select_list.rb | 7 +- .../inputs}/single_version_select_list.rb | 4 +- .../projects/custom_fields/inputs}/string.rb | 2 +- .../projects/custom_fields/inputs}/text.rb | 2 +- app/models/project.rb | 7 +- .../projects/acts_as_customizable_patches.rb | 87 ++++++ .../sections/edit_dialog_component.html.erb | 8 +- .../sections/edit_dialog_component.rb | 68 +---- .../overviews/overviews_controller.rb | 253 ++++-------------- 23 files changed, 326 insertions(+), 341 deletions(-) create mode 100644 app/forms/projects/custom_fields/form.rb rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/base/autocomplete/multi_value_input.rb (75%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/base/autocomplete/single_value_input.rb (83%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/base/autocomplete/user_query_utils.rb (95%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/base/input.rb (78%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/base/utils.rb (74%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/bool.rb (94%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/date.rb (93%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/float.rb (93%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/int.rb (94%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/multi_select_list.rb (89%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/multi_user_select_list.rb (80%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/multi_version_select_list.rb (88%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/single_select_list.rb (83%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/single_user_select_list.rb (82%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/single_version_select_list.rb (89%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/string.rb (93%) rename {modules/overviews/app/forms/project/custom_value_form => app/forms/projects/custom_fields/inputs}/text.rb (95%) create mode 100644 app/models/projects/acts_as_customizable_patches.rb diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb new file mode 100644 index 000000000000..9f62702c3df7 --- /dev/null +++ b/app/forms/projects/custom_fields/form.rb @@ -0,0 +1,115 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Projects::CustomFields + class Form < ApplicationForm + form do |custom_fields_form| + sorted_custom_fields.each do |custom_field| + custom_fields_form.fields_for(:custom_field_values) do |builder| + custom_field_input(builder, custom_field) + end + end + end + + def initialize(project:, custom_field_section: nil) + super() + @project = project + @custom_field_section = custom_field_section + end + + private + + def sorted_custom_fields + # TODO: move to service/model + return @custom_fields if @custom_fields.present? + + @custom_fields ||= @project.available_custom_fields + + if @custom_field_section.present? + @custom_fields = @custom_fields + .where(custom_field_section_id: @custom_field_section.id) + end + + @custom_fields = @custom_fields.sort_by do |pcf| + [pcf.project_custom_field_section.position, pcf.position_in_custom_field_section] + end + end + + def custom_field_input(builder, custom_field) + if custom_field.multi_value? + custom_values = @project.custom_values_for_custom_field(id: custom_field.id) + multi_value_custom_field_input(builder, custom_field, custom_values) + else + custom_value = @project.custom_value_for(custom_field.id) + single_value_custom_field_input(builder, custom_field, custom_value) + end + end + + # TBD: transform inputs called below to primer form dsl instead of form classes? + # TODOS: + # - list inputs cannot be resetted currently (worked before refactoring though) + # - initial values for user inputs are not displayed + + def single_value_custom_field_input(builder, custom_field, custom_value) + form_args = { custom_field:, custom_value:, project: @project } + + case custom_field.field_format + when "string" + Projects::CustomFields::Inputs::String.new(builder, **form_args) + when "text" + Projects::CustomFields::Inputs::Text.new(builder, **form_args) + when "int" + Projects::CustomFields::Inputs::Int.new(builder, **form_args) + when "float" + Projects::CustomFields::Inputs::Float.new(builder, **form_args) + when "list" + Projects::CustomFields::Inputs::SingleSelectList.new(builder, **form_args) + when "date" + Projects::CustomFields::Inputs::Date.new(builder, **form_args) + when "bool" + Projects::CustomFields::Inputs::Bool.new(builder, **form_args) + when "user" + Projects::CustomFields::Inputs::SingleUserSelectList.new(builder, **form_args) + when "version" + Projects::CustomFields::Inputs::SingleVersionSelectList.new(builder, **form_args) + end + end + + def multi_value_custom_field_input(builder, custom_field, custom_values) + form_args = { custom_field:, custom_values:, project: @project } + + case custom_field.field_format + when "list" + Projects::CustomFields::Inputs::MultiSelectList.new(builder, **form_args) + when "user" + Projects::CustomFields::Inputs::MultiUserSelectList.new(builder, **form_args) + when "version" + Projects::CustomFields::Inputs::MultiVersionSelectList.new(builder, **form_args) + end + end + end +end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb b/app/forms/projects/custom_fields/inputs/base/autocomplete/multi_value_input.rb similarity index 75% rename from modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb rename to app/forms/projects/custom_fields/inputs/base/autocomplete/multi_value_input.rb index 9a97e4040d3d..23d602f2debf 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/multi_value_input.rb +++ b/app/forms/projects/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -26,12 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Base::Autocomplete::MultiValueInput < ApplicationForm - include Project::CustomValueForm::Base::Utils +class Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput < ApplicationForm + include Projects::CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_field_values:, project:) + def initialize(custom_field:, custom_values:, project:) @custom_field = custom_field - @custom_field_values = custom_field_values + @custom_values = custom_values @project = project end @@ -57,15 +57,11 @@ def decorated? raise NotImplementedError end - def name - "project[multi_custom_field_values_attributes][#{@custom_field.id}][values]" - end - def invalid? - @custom_field_values.any? { |custom_field_value| custom_field_value.errors.any? } + @custom_values.any? { |custom_value| custom_value.errors.any? } end def validation_message - @custom_field_values.map { |custom_field_value| custom_field_value.errors.full_messages }.join(', ') if invalid? + @custom_values.map { |custom_value| custom_value.errors.full_messages }.join(', ') if invalid? end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb b/app/forms/projects/custom_fields/inputs/base/autocomplete/single_value_input.rb similarity index 83% rename from modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb rename to app/forms/projects/custom_fields/inputs/base/autocomplete/single_value_input.rb index f681142c79f8..d6d11942942d 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/single_value_input.rb +++ b/app/forms/projects/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -26,12 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Base::Autocomplete::SingleValueInput < ApplicationForm - include Project::CustomValueForm::Base::Utils +class Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput < ApplicationForm + include Projects::CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_field_value:, project:) + def initialize(custom_field:, custom_value:, project:) @custom_field = custom_field - @custom_field_value = custom_field_value + @custom_value = custom_value @project = project end @@ -58,10 +58,10 @@ def decorated? end def invalid? - @custom_field_value.errors.any? + @custom_value.errors.any? end def validation_message - @custom_field_value.errors.full_messages.join(', ') if invalid? + @custom_value.errors.full_messages.join(', ') if invalid? end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb b/app/forms/projects/custom_fields/inputs/base/autocomplete/user_query_utils.rb similarity index 95% rename from modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb rename to app/forms/projects/custom_fields/inputs/base/autocomplete/user_query_utils.rb index 99c16b8799f0..dfb07b027f36 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/autocomplete/user_query_utils.rb +++ b/app/forms/projects/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Project::CustomValueForm::Base::Autocomplete::UserQueryUtils +module Projects::CustomFields::Inputs::Base::Autocomplete::UserQueryUtils def user_autocomplete_options { placeholder: I18n.t(:label_user_search), @@ -58,6 +58,7 @@ def input_value end def input_values_filter + # TODO: not working yet user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } id_filter = { "id" => { "operator" => "=", "values" => init_user_ids } } diff --git a/modules/overviews/app/forms/project/custom_value_form/base/input.rb b/app/forms/projects/custom_fields/inputs/base/input.rb similarity index 78% rename from modules/overviews/app/forms/project/custom_value_form/base/input.rb rename to app/forms/projects/custom_fields/inputs/base/input.rb index 3dc03b52b1ed..5d2d5d0c450f 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/input.rb +++ b/app/forms/projects/custom_fields/inputs/base/input.rb @@ -26,12 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Base::Input < ApplicationForm - include Project::CustomValueForm::Base::Utils +class Projects::CustomFields::Inputs::Base::Input < ApplicationForm + include Projects::CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_field_value:, project:) + def initialize(custom_field:, custom_value:, project:) @custom_field = custom_field - @custom_field_value = custom_field_value + @custom_value = custom_value @project = project end @@ -44,15 +44,11 @@ def input_attributes ) end - def value - @custom_field_value.value || @custom_field.default_value - end - def invalid? - @custom_field_value.errors.any? + @custom_value.errors.any? end def validation_message - @custom_field_value.errors.full_messages.join(', ') if invalid? + @custom_value.errors.full_messages.join(', ') if invalid? end end diff --git a/modules/overviews/app/forms/project/custom_value_form/base/utils.rb b/app/forms/projects/custom_fields/inputs/base/utils.rb similarity index 74% rename from modules/overviews/app/forms/project/custom_value_form/base/utils.rb rename to app/forms/projects/custom_fields/inputs/base/utils.rb index df2c9fb39896..0c5dfac89097 100644 --- a/modules/overviews/app/forms/project/custom_value_form/base/utils.rb +++ b/app/forms/projects/custom_fields/inputs/base/utils.rb @@ -26,31 +26,39 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Project::CustomValueForm::Base::Utils +module Projects::CustomFields::Inputs::Base::Utils def base_input_attributes { - name:, id:, - scope_name_to_model: false, - scope_id_to_model: false, - placeholder: @custom_field.name, - label: @custom_field.name, - required: @custom_field.is_required?, + scope_name_to_model: false, # TODO: get rid of this, should work with scope_name_to_model: true + name:, + label:, + value:, + required: required?, invalid: invalid?, validation_message: } end def id - name.gsub(/[\[\]]/, "_") + "custom_field_#{@custom_field.id}" end def name - if @custom_field_value.new_record? - "project[new_custom_field_values_attributes][#{@custom_field_value.custom_field_id}][value]" - else - "project[custom_field_values_attributes][#{@custom_field_value.id}][value]" - end + # TODO: get rid of this, should work with scope_name_to_model: true + "project[custom_field_values][#{@custom_field.id}]" + end + + def label + @custom_field.name + end + + def value + @custom_value + end + + def required? + @custom_field.is_required? end def qa_field_name diff --git a/modules/overviews/app/forms/project/custom_value_form/bool.rb b/app/forms/projects/custom_fields/inputs/bool.rb similarity index 94% rename from modules/overviews/app/forms/project/custom_value_form/bool.rb rename to app/forms/projects/custom_fields/inputs/bool.rb index 29cd0f79dc08..161b3a009ded 100644 --- a/modules/overviews/app/forms/project/custom_value_form/bool.rb +++ b/app/forms/projects/custom_fields/inputs/bool.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Bool < Project::CustomValueForm::Base::Input +class Projects::CustomFields::Inputs::Bool < Projects::CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.check_box(**input_attributes) end diff --git a/modules/overviews/app/forms/project/custom_value_form/date.rb b/app/forms/projects/custom_fields/inputs/date.rb similarity index 93% rename from modules/overviews/app/forms/project/custom_value_form/date.rb rename to app/forms/projects/custom_fields/inputs/date.rb index 00b9cc8cc639..6eaa4297a02a 100644 --- a/modules/overviews/app/forms/project/custom_value_form/date.rb +++ b/app/forms/projects/custom_fields/inputs/date.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Date < Project::CustomValueForm::Base::Input +class Projects::CustomFields::Inputs::Date < Projects::CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/modules/overviews/app/forms/project/custom_value_form/float.rb b/app/forms/projects/custom_fields/inputs/float.rb similarity index 93% rename from modules/overviews/app/forms/project/custom_value_form/float.rb rename to app/forms/projects/custom_fields/inputs/float.rb index 2d12a23b91d4..9a4c796ff965 100644 --- a/modules/overviews/app/forms/project/custom_value_form/float.rb +++ b/app/forms/projects/custom_fields/inputs/float.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Float < Project::CustomValueForm::Base::Input +class Projects::CustomFields::Inputs::Float < Projects::CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/modules/overviews/app/forms/project/custom_value_form/int.rb b/app/forms/projects/custom_fields/inputs/int.rb similarity index 94% rename from modules/overviews/app/forms/project/custom_value_form/int.rb rename to app/forms/projects/custom_fields/inputs/int.rb index 2f6590c86756..e89878088901 100644 --- a/modules/overviews/app/forms/project/custom_value_form/int.rb +++ b/app/forms/projects/custom_fields/inputs/int.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Int < Project::CustomValueForm::Base::Input +class Projects::CustomFields::Inputs::Int < Projects::CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb b/app/forms/projects/custom_fields/inputs/multi_select_list.rb similarity index 89% rename from modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb rename to app/forms/projects/custom_fields/inputs/multi_select_list.rb index ca9e26ecd5ab..c117ad3df29a 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/multi_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::MultiSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput +class Projects::CustomFields::Inputs::MultiSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput form do |custom_value_form| custom_value_form.autocompleter(**input_attributes) do |list| @custom_field.custom_options.each do |custom_option| @@ -45,7 +45,7 @@ def decorated? end def selected?(custom_option) - cf_values = @custom_field_values.reject { |custom_field_value| custom_field_value.id.nil? } + cf_values = @custom_values.reject { |custom_value| custom_value.id.nil? } if cf_values.any? cf_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb b/app/forms/projects/custom_fields/inputs/multi_user_select_list.rb similarity index 80% rename from modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb rename to app/forms/projects/custom_fields/inputs/multi_user_select_list.rb index 360c22880ffd..d75890f66041 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_user_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/multi_user_select_list.rb @@ -26,10 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::MultiUserSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput - include Project::CustomValueForm::Base::Autocomplete::UserQueryUtils +class Projects::CustomFields::Inputs::MultiUserSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput + include Projects::CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| + # TODO: use user_autocompleter as seen on sharing form instead custom_value_form.autocompleter(**input_attributes) end @@ -44,10 +45,6 @@ def autocomplete_options end def init_user_ids - @custom_field_values.map(&:value) - end - - def name - "project[multi_user_custom_field_values_attributes][#{@custom_field.id}][comma_seperated_values][]" + @custom_values.map(&:value) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb b/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb similarity index 88% rename from modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb rename to app/forms/projects/custom_fields/inputs/multi_version_select_list.rb index 716ff1749d0d..979119e0dabd 100644 --- a/modules/overviews/app/forms/project/custom_value_form/multi_version_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::MultiVersionSelectList < Project::CustomValueForm::Base::Autocomplete::MultiValueInput +class Projects::CustomFields::Inputs::MultiVersionSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput form do |custom_value_form| custom_value_form.autocompleter(**input_attributes) do |list| @project.versions.each do |version| @@ -45,6 +45,6 @@ def decorated? end def selected?(version) - @custom_field_values.pluck(:value).map { |value| value&.to_i }.include?(version.id) + @custom_values.pluck(:value).map { |value| value&.to_i }.include?(version.id) end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb b/app/forms/projects/custom_fields/inputs/single_select_list.rb similarity index 83% rename from modules/overviews/app/forms/project/custom_value_form/single_select_list.rb rename to app/forms/projects/custom_fields/inputs/single_select_list.rb index 74d553784f4a..304bfb8d223e 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/single_select_list.rb @@ -26,10 +26,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::SingleSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput +class Projects::CustomFields::Inputs::SingleSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput form do |custom_value_form| custom_value_form.autocompleter(**input_attributes) do |list| - @custom_field_value.custom_field.custom_options.each do |custom_option| + @custom_value.custom_field.custom_options.each do |custom_option| list.option( label: custom_option.value, value: custom_option.id, selected: selected?(custom_option) @@ -45,6 +45,6 @@ def decorated? end def selected?(custom_option) - custom_option.id == @custom_field_value.value&.to_i || custom_option.id == @custom_field.default_value&.to_i + custom_option.id == @custom_value.value&.to_i || custom_option.id == @custom_field.default_value&.to_i end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb b/app/forms/projects/custom_fields/inputs/single_user_select_list.rb similarity index 82% rename from modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb rename to app/forms/projects/custom_fields/inputs/single_user_select_list.rb index f7261a8ee0dd..4dab874f24d8 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_user_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/single_user_select_list.rb @@ -26,10 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::SingleUserSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput - include Project::CustomValueForm::Base::Autocomplete::UserQueryUtils +class Projects::CustomFields::Inputs::SingleUserSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput + include Projects::CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| + # TODO: use user_autocompleter as seen on sharing form instead custom_value_form.autocompleter(**input_attributes) end @@ -44,6 +45,6 @@ def autocomplete_options end def init_user_ids - [@custom_field_value.value] + [@custom_value.value] end end diff --git a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb b/app/forms/projects/custom_fields/inputs/single_version_select_list.rb similarity index 89% rename from modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb rename to app/forms/projects/custom_fields/inputs/single_version_select_list.rb index 769995409660..dd99e504750c 100644 --- a/modules/overviews/app/forms/project/custom_value_form/single_version_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/single_version_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::SingleVersionSelectList < Project::CustomValueForm::Base::Autocomplete::SingleValueInput +class Projects::CustomFields::Inputs::SingleVersionSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput form do |custom_value_form| custom_value_form.autocompleter(**input_attributes) do |list| @project.versions.each do |version| @@ -45,6 +45,6 @@ def decorated? end def selected?(version) - version.id == @custom_field_value.value&.to_i + version.id == @custom_value.value&.to_i end end diff --git a/modules/overviews/app/forms/project/custom_value_form/string.rb b/app/forms/projects/custom_fields/inputs/string.rb similarity index 93% rename from modules/overviews/app/forms/project/custom_value_form/string.rb rename to app/forms/projects/custom_fields/inputs/string.rb index 25dfe7faa1e4..0205cf602c9a 100644 --- a/modules/overviews/app/forms/project/custom_value_form/string.rb +++ b/app/forms/projects/custom_fields/inputs/string.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::String < Project::CustomValueForm::Base::Input +class Projects::CustomFields::Inputs::String < Projects::CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/modules/overviews/app/forms/project/custom_value_form/text.rb b/app/forms/projects/custom_fields/inputs/text.rb similarity index 95% rename from modules/overviews/app/forms/project/custom_value_form/text.rb rename to app/forms/projects/custom_fields/inputs/text.rb index 2e06d9f8a8e8..c6683305fea0 100644 --- a/modules/overviews/app/forms/project/custom_value_form/text.rb +++ b/app/forms/projects/custom_fields/inputs/text.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Project::CustomValueForm::Text < Project::CustomValueForm::Base::Input +class Projects::CustomFields::Inputs::Text < Projects::CustomFields::Inputs::Base::Input form do |custom_value_form| # TODO: rich_text_area not working yet # Uncaught DOMException: Failed to execute 'querySelector' on 'Element': '#project_project[new_custom_field_values_attributes][xyz][value]' is not a valid selector. diff --git a/app/models/project.rb b/app/models/project.rb index 3900ad4354f2..0691b796701d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,6 +33,7 @@ class Project < ApplicationRecord include Projects::Activity include Projects::Hierarchy include Projects::AncestorsFromRoot + include Projects::ActsAsCustomizablePatches include ::Scopes::Scoped # Maximum length for project identifiers @@ -41,8 +42,6 @@ class Project < ApplicationRecord # reserved identifiers RESERVED_IDENTIFIERS = %w(new menu).freeze - # belongs_to :project_type ---> mimic `work_package -> type -> custom_fields` structure ? - has_many :members, -> { # TODO: check whether this should # remain to be limited to User only @@ -89,7 +88,9 @@ class Project < ApplicationRecord has_many :project_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage' has_many :storages, through: :project_storages - acts_as_customizable + acts_as_customizable # partially overridden via Projects::ActsAsCustomizablePatches in order to support sections and + # project-leval activation of custom fields + acts_as_searchable columns: %W(#{table_name}.name #{table_name}.identifier #{table_name}.description), date_column: "#{table_name}.created_at", project_key: 'id', diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb new file mode 100644 index 000000000000..d3cf2f0825e2 --- /dev/null +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -0,0 +1,87 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Projects::ActsAsCustomizablePatches + extend ActiveSupport::Concern + + included do + def active_custom_field_ids_of_project + @active_custom_field_ids_of_project ||= ProjectCustomFieldProjectMapping + .where(project_id: project.id) + .pluck(:custom_field_id) + end + + def available_custom_fields + # overrides acts_as_customizable + # in contrast to acts_as_customizable, custom_fields are enabled per project + # thus we need to check the project_custom_field_project_mappings + @available_custom_fields ||= ProjectCustomField + .includes(:project_custom_field_section) + .where(id: active_custom_field_ids_of_project) + end + + def custom_field_section_ids + # we need to check if a project custom field belongs to a specific section when validating + # we need a mapping of custom_field_id => custom_field_section_id as we don't want to + # change the code of acts_as_customizable for `custom_field_values` which does not include the custom_field_section_id + # preloading a hash avoids n+1 queries while validating + CustomField + .where(id: custom_field_values.pluck(:custom_field_id)) + .pluck(:id, :custom_field_section_id) + .to_h + end + + def validate_custom_values + # overrides acts_as_customizable + # validate custom values only of the touched section + # instead of validating ALL custom values like done in acts_as_customizable + set_default_values! if new_record? + + custom_field_values + .select { |custom_value| of_touched_custom_field_section?(custom_value) } + .reject(&:marked_for_destruction?) + .select(&:invalid?) + .each { |custom_value| add_custom_value_errors! custom_value } + end + + def of_touched_custom_field_section?(custom_value) + if @touched_section_id.present? + custom_field_section_ids[custom_value.custom_field_id] == @touched_section_id + else + true # validate all custom values if no specific section was marked as touched via `update_custom_field_values_of_section` + end + end + + def update_custom_field_values_of_section(section, params) + # we need to set the touched section id to validate only the custom values of the touched section + @touched_section_id = section.id + + update(params) + end + end +end diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index 83cb4ae632b6..ea0e6c620721 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -9,12 +9,8 @@ ) do |f| component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 500px;")) do - flex_layout(my: 3) do |flex| - @active_project_custom_fields_of_section.each do |project_custom_field| - flex.with_row(mb: 2, classes: 'op-project-custom-field-input-container', data: { qa_selector: "project-custom-field-input-container-#{project_custom_field.id}" }) do - render_custom_field_value_input(f, project_custom_field, project_custom_field_values_for(project_custom_field.id)) - end - end + f.fields_for(:custom_field_values) do |f| + render(Projects::CustomFields::Form.new(f, project: @project, custom_field_section: @project_custom_field_section)) end end collection.with_component(Primer::Alpha::Dialog::Footer.new) do diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb index 6e7fd1df73af..29d2f3d68fdc 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb @@ -34,77 +34,11 @@ class EditDialogComponent < ApplicationComponent include OpPrimer::ComponentHelpers def initialize(project:, - project_custom_field_section:, - active_project_custom_fields_of_section:, - project_custom_field_values:) + project_custom_field_section:) super @project = project @project_custom_field_section = project_custom_field_section - @active_project_custom_fields_of_section = active_project_custom_fields_of_section - @project_custom_field_values = project_custom_field_values - end - - private - - def project_custom_field_values_for(project_custom_field_id) - values = @project_custom_field_values.select { |pcfv| pcfv.custom_field_id == project_custom_field_id } - - if values.empty? - [CustomValue.new( - custom_field_id: project_custom_field_id, - customized_id: @project.id, - customized_type: "Project" - )] - else - values - end - end - - def render_custom_field_value_input(form, custom_field, custom_field_values) - if custom_field.multi_value? - render_multi_value_custom_field_input(form, custom_field, custom_field_values) - else - render_single_value_custom_field_input(form, custom_field, custom_field_values.first) - end - end - - def render_single_value_custom_field_input(form, custom_field, custom_field_value) - form_args = { custom_field:, custom_field_value:, project: @project } - - case custom_field.field_format - when "string" - render(Project::CustomValueForm::String.new(form, **form_args)) - when "text" - render(Project::CustomValueForm::Text.new(form, **form_args)) - when "int" - render(Project::CustomValueForm::Int.new(form, **form_args)) - when "float" - render(Project::CustomValueForm::Float.new(form, **form_args)) - when "list" - render(Project::CustomValueForm::SingleSelectList.new(form, **form_args)) - when "date" - render(Project::CustomValueForm::Date.new(form, **form_args)) - when "bool" - render(Project::CustomValueForm::Bool.new(form, **form_args)) - when "user" - render(Project::CustomValueForm::SingleUserSelectList.new(form, **form_args)) - when "version" - render(Project::CustomValueForm::SingleVersionSelectList.new(form, **form_args)) - end - end - - def render_multi_value_custom_field_input(form, custom_field, custom_field_values) - form_args = { custom_field:, custom_field_values:, project: @project } - - case custom_field.field_format - when "list" - render(Project::CustomValueForm::MultiSelectList.new(form, **form_args)) - when "user" - render(Project::CustomValueForm::MultiUserSelectList.new(form, **form_args)) - when "version" - render(Project::CustomValueForm::MultiVersionSelectList.new(form, **form_args)) - end end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 9d8bd5342fb1..3613c5927c4b 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -9,6 +9,7 @@ class OverviewsController < ::Grids::BaseInProjectController menu_item :overview def attributes_sidebar + # TODO: check permissions render( ProjectCustomFields::SidebarComponent.new( project: @project, @@ -20,44 +21,30 @@ def attributes_sidebar end def attribute_section_dialog - section = ProjectCustomFieldSection.find(params[:section_id]) - - eager_loaded_project_custom_field_values = CustomValue.where( - custom_field_id: active_project_custom_fields_of_section(section.id).pluck(:id), - customized_id: @project.id - ).to_a + # TODO: check permissions + @section = find_project_custom_field_section render( ProjectCustomFields::Sections::EditDialogComponent.new( project: @project, - project_custom_field_section: section, - active_project_custom_fields_of_section: active_project_custom_fields_of_section(section.id), - project_custom_field_values: eager_loaded_project_custom_field_values + project_custom_field_section: @section ), layout: false ) end def update_attributes - section = find_project_custom_field_section - - has_errors = false - - ActiveRecord::Base.transaction do - modified_custom_field_values = modify_custom_field_values(section) - modified_custom_field_values = add_missing_required_custom_values(section, modified_custom_field_values) + @section = find_project_custom_field_section - has_errors = modified_custom_field_values.any?(&:invalid?) + # TODO: transform to contract/service-based approach with permission checks + @project.update_custom_field_values_of_section(@section, project_attribute_params) - if has_errors - handle_errors(section, modified_custom_field_values) - else - save_custom_field_values(modified_custom_field_values) - delete_missing_custom_field_values(section, modified_custom_field_values) - delete_unused_multi_values(unused_multi_values(section)) + has_errors = @project.errors.any? - update_sidebar_component - end + if has_errors + handle_errors + else + update_sidebar_component end respond_to_with_turbo_streams(status: has_errors ? :unprocessable_entity : :ok) @@ -78,15 +65,12 @@ def check_project_attributes_feature_enabled def project_attribute_params params.require(:project).permit( - custom_field_values_attributes: [:value], - new_custom_field_values_attributes: [:value], - multi_user_custom_field_values_attributes: [:custom_field_id, { comma_seperated_values: [] }], - multi_custom_field_values_attributes: [:custom_field_id, { values: [] }] + custom_field_values: {} ) end def active_project_custom_fields_grouped_by_section - # TODO: move to service + # TODO: move to service/model active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping .where(project_id: @project.id) .pluck(:custom_field_id) @@ -107,183 +91,15 @@ def find_project_custom_field_section ProjectCustomFieldSection.find(params[:section_id]) end - def modify_custom_field_values(section) - custom_field_values = [] - - transaction_custom_field_values(section, :custom_field_values_attributes) do |custom_value_id, attributes| - custom_value = update_custom_value(custom_value_id, attributes) - custom_field_values << custom_value - end - - transaction_custom_field_values(section, :new_custom_field_values_attributes) do |custom_field_id, attributes| - custom_value = build_new_custom_value(custom_field_id, attributes) - custom_field_values << custom_value - end - - transaction_custom_field_values(section, :multi_custom_field_values_attributes) do |custom_field_id, attributes| - custom_field_values.concat(update_multi_custom_field_values(custom_field_id, attributes)) - end - - transaction_custom_field_values(section, :multi_user_custom_field_values_attributes) do |custom_field_id, attributes| - custom_field_values.concat(update_multi_user_custom_field_values(custom_field_id, attributes)) - end - - custom_field_values - end - - def unused_multi_values(section) - custom_field_values = [] - - transaction_custom_field_values(section, :multi_custom_field_values_attributes) do |custom_field_id, attributes| - custom_field_values.concat(detect_unused_multi_values(custom_field_id, attributes)) - end - - transaction_custom_field_values(section, :multi_user_custom_field_values_attributes) do |custom_field_id, attributes| - custom_field_values.concat(detect_unused_user_multi_values(custom_field_id, attributes)) - end - - custom_field_values - end - - def transaction_custom_field_values(_section, attribute_key) - project_attribute_params[attribute_key]&.each do |custom_value_id, attributes| - yield(custom_value_id.to_i, attributes) - end - end - - def update_custom_value(custom_value_id, attributes) - custom_value = CustomValue.find(custom_value_id.to_i) - custom_value.value = attributes[:value] - custom_value - end - - def build_new_custom_value(custom_field_id, attributes) - CustomValue.new( - custom_field_id: custom_field_id.to_i, - value: attributes[:value], - customized_type: "Project", - customized_id: @project.id - ) - end - - def update_multi_custom_field_values(custom_field_id, attributes) - custom_field_values = [] - - existing_values_to_keep = attributes[:values] || [] - - existing_values_to_keep.each do |value| - custom_value = find_or_initialize_custom_value(custom_field_id, value) - custom_field_values << custom_value - end - - custom_field_values - end - - def update_multi_user_custom_field_values(custom_field_id, attributes) - custom_field_values = [] - - existing_values_to_keep = attributes[:comma_seperated_values][0]&.split(',') || [] - - existing_values_to_keep.each do |value| - custom_value = find_or_initialize_custom_value(custom_field_id, value) - custom_field_values << custom_value - end - - custom_field_values - end - - def detect_unused_multi_values(custom_field_id, attributes) - existing_values_to_keep = attributes[:values] || [] - unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) - end - - def detect_unused_user_multi_values(custom_field_id, attributes) - existing_values_to_keep = attributes[:comma_seperated_values][0]&.split(',') || [] - unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) - end - - def unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) - @project.custom_values - .where(custom_field_id: custom_field_id.to_i) - .where.not(value: existing_values_to_keep) - .to_a - end - - def find_or_initialize_custom_value(custom_field_id, value) - CustomValue.find_or_initialize_by( - custom_field_id: custom_field_id.to_i, - value:, - customized_type: "Project", - customized_id: @project.id - ) - end - - def handle_errors(section, modified_custom_field_values) + def handle_errors update_via_turbo_stream( component: ProjectCustomFields::Sections::EditDialogComponent.new( project: @project, - project_custom_field_section: section, - active_project_custom_fields_of_section: active_project_custom_fields_of_section(section.id), - project_custom_field_values: modified_custom_field_values + project_custom_field_section: @section ) ) end - def save_custom_field_values(modified_custom_field_values) - modified_custom_field_values.each(&:save!) - end - - def handle_missing_values(section, modified_custom_field_values) - mark_missing_values_as_required(section, modified_custom_field_values) - end - - def get_missing_custom_field_ids(section, modified_custom_field_values) - custom_field_ids_of_section = active_project_custom_fields_of_section(section.id).pluck(:id) - modified_custom_field_ids = modified_custom_field_values.pluck(:custom_field_id) - - custom_field_ids_of_section - modified_custom_field_ids - end - - def delete_missing_custom_field_values(section, modified_custom_field_values) - missing_custom_field_ids = get_missing_custom_field_ids(section, modified_custom_field_values) - - non_required_custom_field_ids = ProjectCustomField - .where(id: missing_custom_field_ids) - .where.not(is_required: true) - .pluck(:id) - - CustomValue - .where(custom_field_id: non_required_custom_field_ids, customized_id: @project.id) - .destroy_all - end - - def delete_unused_multi_values(custom_values_to_be_deleted) - custom_values_to_be_deleted.each(&:destroy!) - end - - def add_missing_required_custom_values(section, modified_custom_field_values) - missing_custom_field_ids = get_missing_custom_field_ids(section, modified_custom_field_values) - - required_custom_field_ids = ProjectCustomField - .where(id: missing_custom_field_ids) - .where(is_required: true) - .pluck(:id) - - required_custom_field_ids.each do |custom_field_id| - custom_value = CustomValue.find_or_initialize_by( - custom_field_id: custom_field_id.to_i, - customized_type: "Project", - customized_id: @project.id - ) - custom_value.value = nil - custom_value.errors.add(:value, :blank) - - modified_custom_field_values << custom_value - end - - modified_custom_field_values - end - def update_sidebar_component update_via_turbo_stream( component: ProjectCustomFields::SidebarComponent.new( @@ -293,5 +109,42 @@ def update_sidebar_component ) ) end + + # resetting list values not working after refactoring, leave old code here for reference + # + # def unused_multi_values(section) + # custom_field_values = [] + + # transaction_custom_field_values(section, :multi_custom_field_values_attributes) do |custom_field_id, attributes| + # custom_field_values.concat(detect_unused_multi_values(custom_field_id, attributes)) + # end + + # transaction_custom_field_values(section, :multi_user_custom_field_values_attributes) do |custom_field_id, attributes| + # custom_field_values.concat(detect_unused_user_multi_values(custom_field_id, attributes)) + # end + + # custom_field_values + # end + + # def detect_unused_multi_values(custom_field_id, attributes) + # existing_values_to_keep = attributes[:values] || [] + # unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) + # end + + # def detect_unused_user_multi_values(custom_field_id, attributes) + # existing_values_to_keep = attributes[:comma_seperated_values][0]&.split(',') || [] + # unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) + # end + + # def unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) + # @project.custom_values + # .where(custom_field_id: custom_field_id.to_i) + # .where.not(value: existing_values_to_keep) + # .to_a + # end + + # def delete_unused_multi_values(custom_values_to_be_deleted) + # custom_values_to_be_deleted.each(&:destroy!) + # end end end From 9e23d43e2a813ca43ff791d92682da1ebe36f445 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 30 Jan 2024 17:22:51 +0700 Subject: [PATCH 047/218] fixed resetting of list fields --- app/forms/projects/custom_fields/form.rb | 2 +- .../custom_fields/inputs/multi_select_list.rb | 5 +++ .../inputs/multi_version_select_list.rb | 5 +++ .../inputs/single_select_list.rb | 5 +++ .../inputs/single_version_select_list.rb | 6 +++ .../overviews/overviews_controller.rb | 45 +------------------ 6 files changed, 23 insertions(+), 45 deletions(-) diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb index 9f62702c3df7..8358c9431a25 100644 --- a/app/forms/projects/custom_fields/form.rb +++ b/app/forms/projects/custom_fields/form.rb @@ -71,8 +71,8 @@ def custom_field_input(builder, custom_field) # TBD: transform inputs called below to primer form dsl instead of form classes? # TODOS: - # - list inputs cannot be resetted currently (worked before refactoring though) # - initial values for user inputs are not displayed + # - allow/disallow-non-open version setting is not yet respected in the version selector def single_value_custom_field_input(builder, custom_field, custom_value) form_args = { custom_field:, custom_value:, project: @project } diff --git a/app/forms/projects/custom_fields/inputs/multi_select_list.rb b/app/forms/projects/custom_fields/inputs/multi_select_list.rb index c117ad3df29a..f7acb050b3a4 100644 --- a/app/forms/projects/custom_fields/inputs/multi_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/multi_select_list.rb @@ -28,6 +28,11 @@ class Projects::CustomFields::Inputs::MultiSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge(name: "#{input_attributes[:name]}[]", value:)) + custom_value_form.autocompleter(**input_attributes) do |list| @custom_field.custom_options.each do |custom_option| list.option( diff --git a/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb b/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb index 979119e0dabd..8094ce74e708 100644 --- a/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb @@ -28,6 +28,11 @@ class Projects::CustomFields::Inputs::MultiVersionSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge(name: "#{input_attributes[:name]}[]", value:)) + custom_value_form.autocompleter(**input_attributes) do |list| @project.versions.each do |version| list.option( diff --git a/app/forms/projects/custom_fields/inputs/single_select_list.rb b/app/forms/projects/custom_fields/inputs/single_select_list.rb index 304bfb8d223e..4e326ac3caa8 100644 --- a/app/forms/projects/custom_fields/inputs/single_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/single_select_list.rb @@ -28,6 +28,11 @@ class Projects::CustomFields::Inputs::SingleSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge(value: "")) + custom_value_form.autocompleter(**input_attributes) do |list| @custom_value.custom_field.custom_options.each do |custom_option| list.option( diff --git a/app/forms/projects/custom_fields/inputs/single_version_select_list.rb b/app/forms/projects/custom_fields/inputs/single_version_select_list.rb index dd99e504750c..8a5b6bb90031 100644 --- a/app/forms/projects/custom_fields/inputs/single_version_select_list.rb +++ b/app/forms/projects/custom_fields/inputs/single_version_select_list.rb @@ -28,7 +28,13 @@ class Projects::CustomFields::Inputs::SingleVersionSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge(value: "")) + custom_value_form.autocompleter(**input_attributes) do |list| + # TODO: allow-non-open version setting is not yet respected! @project.versions.each do |version| list.option( label: version.name, value: version.id, diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 3613c5927c4b..b0e780fd1a71 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -37,7 +37,7 @@ def update_attributes @section = find_project_custom_field_section # TODO: transform to contract/service-based approach with permission checks - @project.update_custom_field_values_of_section(@section, project_attribute_params) + @project.update_custom_field_values_of_section(@section, permitted_params.project) has_errors = @project.errors.any? @@ -63,12 +63,6 @@ def check_project_attributes_feature_enabled render_404 unless OpenProject::FeatureDecisions.project_attributes_active? end - def project_attribute_params - params.require(:project).permit( - custom_field_values: {} - ) - end - def active_project_custom_fields_grouped_by_section # TODO: move to service/model active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping @@ -109,42 +103,5 @@ def update_sidebar_component ) ) end - - # resetting list values not working after refactoring, leave old code here for reference - # - # def unused_multi_values(section) - # custom_field_values = [] - - # transaction_custom_field_values(section, :multi_custom_field_values_attributes) do |custom_field_id, attributes| - # custom_field_values.concat(detect_unused_multi_values(custom_field_id, attributes)) - # end - - # transaction_custom_field_values(section, :multi_user_custom_field_values_attributes) do |custom_field_id, attributes| - # custom_field_values.concat(detect_unused_user_multi_values(custom_field_id, attributes)) - # end - - # custom_field_values - # end - - # def detect_unused_multi_values(custom_field_id, attributes) - # existing_values_to_keep = attributes[:values] || [] - # unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) - # end - - # def detect_unused_user_multi_values(custom_field_id, attributes) - # existing_values_to_keep = attributes[:comma_seperated_values][0]&.split(',') || [] - # unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) - # end - - # def unused_multi_values_to_be_deleted(custom_field_id, existing_values_to_keep) - # @project.custom_values - # .where(custom_field_id: custom_field_id.to_i) - # .where.not(value: existing_values_to_keep) - # .to_a - # end - - # def delete_unused_multi_values(custom_values_to_be_deleted) - # custom_values_to_be_deleted.each(&:destroy!) - # end end end From 8ec7010f3a2a03323feda7fd63b7c0f06d881048 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 1 Feb 2024 13:07:13 +0700 Subject: [PATCH 048/218] WIP: generalize custom field inputs a bit more, support single custom field form, minor refactoring --- .../base/autocomplete/multi_value_input.rb | 8 +-- .../base/autocomplete/single_value_input.rb | 8 +-- .../base/autocomplete/user_query_utils.rb | 16 +++-- .../custom_fields/inputs/base/input.rb | 8 +-- .../custom_fields/inputs/base/utils.rb | 4 +- .../custom_fields/inputs/bool.rb | 2 +- .../custom_fields/inputs/date.rb | 2 +- .../custom_fields/inputs/float.rb | 2 +- .../custom_fields/inputs/int.rb | 2 +- .../custom_fields/inputs/multi_select_list.rb | 2 +- .../inputs/multi_user_select_list.rb | 4 +- .../inputs/multi_version_select_list.rb | 4 +- .../inputs/single_select_list.rb | 2 +- .../inputs/single_user_select_list.rb | 6 +- .../inputs/single_version_select_list.rb | 4 +- .../custom_fields/inputs/string.rb | 2 +- .../custom_fields/inputs/text.rb | 2 +- app/forms/projects/custom_fields/form.rb | 67 ++++++++++--------- .../projects/acts_as_customizable_patches.rb | 15 +++++ .../sections/edit_dialog_component.html.erb | 2 +- 20 files changed, 91 insertions(+), 71 deletions(-) rename app/forms/{projects => }/custom_fields/inputs/base/autocomplete/multi_value_input.rb (89%) rename app/forms/{projects => }/custom_fields/inputs/base/autocomplete/single_value_input.rb (88%) rename app/forms/{projects => }/custom_fields/inputs/base/autocomplete/user_query_utils.rb (77%) rename app/forms/{projects => }/custom_fields/inputs/base/input.rb (88%) rename app/forms/{projects => }/custom_fields/inputs/base/utils.rb (93%) rename app/forms/{projects => }/custom_fields/inputs/bool.rb (94%) rename app/forms/{projects => }/custom_fields/inputs/date.rb (93%) rename app/forms/{projects => }/custom_fields/inputs/float.rb (93%) rename app/forms/{projects => }/custom_fields/inputs/int.rb (94%) rename app/forms/{projects => }/custom_fields/inputs/multi_select_list.rb (94%) rename app/forms/{projects => }/custom_fields/inputs/multi_user_select_list.rb (88%) rename app/forms/{projects => }/custom_fields/inputs/multi_version_select_list.rb (92%) rename app/forms/{projects => }/custom_fields/inputs/single_select_list.rb (94%) rename app/forms/{projects => }/custom_fields/inputs/single_user_select_list.rb (86%) rename app/forms/{projects => }/custom_fields/inputs/single_version_select_list.rb (91%) rename app/forms/{projects => }/custom_fields/inputs/string.rb (93%) rename app/forms/{projects => }/custom_fields/inputs/text.rb (95%) diff --git a/app/forms/projects/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb similarity index 89% rename from app/forms/projects/custom_fields/inputs/base/autocomplete/multi_value_input.rb rename to app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb index 23d602f2debf..505295bb706b 100644 --- a/app/forms/projects/custom_fields/inputs/base/autocomplete/multi_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -26,13 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput < ApplicationForm - include Projects::CustomFields::Inputs::Base::Utils +class CustomFields::Inputs::Base::Autocomplete::MultiValueInput < ApplicationForm + include CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_values:, project:) + def initialize(custom_field:, custom_values:, object:) @custom_field = custom_field @custom_values = custom_values - @project = project + @object = object end def input_attributes diff --git a/app/forms/projects/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb similarity index 88% rename from app/forms/projects/custom_fields/inputs/base/autocomplete/single_value_input.rb rename to app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb index d6d11942942d..b533da6f7a90 100644 --- a/app/forms/projects/custom_fields/inputs/base/autocomplete/single_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -26,13 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput < ApplicationForm - include Projects::CustomFields::Inputs::Base::Utils +class CustomFields::Inputs::Base::Autocomplete::SingleValueInput < ApplicationForm + include CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_value:, project:) + def initialize(custom_field:, custom_value:, object:) @custom_field = custom_field @custom_value = custom_value - @project = project + @object = object end def input_attributes diff --git a/app/forms/projects/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb similarity index 77% rename from app/forms/projects/custom_fields/inputs/base/autocomplete/user_query_utils.rb rename to app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index dfb07b027f36..c487460a200f 100644 --- a/app/forms/projects/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -26,14 +26,16 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Projects::CustomFields::Inputs::Base::Autocomplete::UserQueryUtils +module CustomFields::Inputs::Base::Autocomplete::UserQueryUtils def user_autocomplete_options { placeholder: I18n.t(:label_user_search), resource:, + # url: ::API::V3::Utilities::PathHelper::ApiV3Path.users, filters:, searchKey: search_key, - inputValue: input_value + inputValue: input_value, + focusDirectly: false } end @@ -48,18 +50,18 @@ def search_key def filters [ { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, - { name: 'member', operator: '=', values: [@project.id.to_s] }, - { name: 'status', operator: '!', values: [User.statuses["locked"].to_s] } + { name: 'member', operator: '=', values: [@object.id.to_s] }, + { name: 'status', operator: '!', values: [Principal.statuses["locked"].to_s] } ] end def input_value - "?#{input_values_filter}" + "?#{input_values_filter}" unless init_user_ids.empty? end def input_values_filter - # TODO: not working yet - user_filter = { "type" => { "operator" => "=", "values" => ["User"] } } + # TODO: not working yet, would work with resource "users" and simple ids, but then the options cannot be loaded + user_filter = { "type" => { "operator" => "=", "values" => ["User", "Group", "PlaceholderUser"] } } id_filter = { "id" => { "operator" => "=", "values" => init_user_ids } } filters = [user_filter, id_filter] diff --git a/app/forms/projects/custom_fields/inputs/base/input.rb b/app/forms/custom_fields/inputs/base/input.rb similarity index 88% rename from app/forms/projects/custom_fields/inputs/base/input.rb rename to app/forms/custom_fields/inputs/base/input.rb index 5d2d5d0c450f..e8b69c876579 100644 --- a/app/forms/projects/custom_fields/inputs/base/input.rb +++ b/app/forms/custom_fields/inputs/base/input.rb @@ -26,13 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Base::Input < ApplicationForm - include Projects::CustomFields::Inputs::Base::Utils +class CustomFields::Inputs::Base::Input < ApplicationForm + include CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_value:, project:) + def initialize(custom_field:, custom_value:, object:) @custom_field = custom_field @custom_value = custom_value - @project = project + @object = object end def input_attributes diff --git a/app/forms/projects/custom_fields/inputs/base/utils.rb b/app/forms/custom_fields/inputs/base/utils.rb similarity index 93% rename from app/forms/projects/custom_fields/inputs/base/utils.rb rename to app/forms/custom_fields/inputs/base/utils.rb index 0c5dfac89097..0f8f1a4dc43a 100644 --- a/app/forms/projects/custom_fields/inputs/base/utils.rb +++ b/app/forms/custom_fields/inputs/base/utils.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Projects::CustomFields::Inputs::Base::Utils +module CustomFields::Inputs::Base::Utils def base_input_attributes { id:, @@ -46,7 +46,7 @@ def id def name # TODO: get rid of this, should work with scope_name_to_model: true - "project[custom_field_values][#{@custom_field.id}]" + "#{@object.class.name.downcase}[custom_field_values][#{@custom_field.id}]" end def label diff --git a/app/forms/projects/custom_fields/inputs/bool.rb b/app/forms/custom_fields/inputs/bool.rb similarity index 94% rename from app/forms/projects/custom_fields/inputs/bool.rb rename to app/forms/custom_fields/inputs/bool.rb index 161b3a009ded..c3e6b1e22373 100644 --- a/app/forms/projects/custom_fields/inputs/bool.rb +++ b/app/forms/custom_fields/inputs/bool.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Bool < Projects::CustomFields::Inputs::Base::Input +class CustomFields::Inputs::Bool < CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.check_box(**input_attributes) end diff --git a/app/forms/projects/custom_fields/inputs/date.rb b/app/forms/custom_fields/inputs/date.rb similarity index 93% rename from app/forms/projects/custom_fields/inputs/date.rb rename to app/forms/custom_fields/inputs/date.rb index 6eaa4297a02a..2834b3a8a60f 100644 --- a/app/forms/projects/custom_fields/inputs/date.rb +++ b/app/forms/custom_fields/inputs/date.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Date < Projects::CustomFields::Inputs::Base::Input +class CustomFields::Inputs::Date < CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/app/forms/projects/custom_fields/inputs/float.rb b/app/forms/custom_fields/inputs/float.rb similarity index 93% rename from app/forms/projects/custom_fields/inputs/float.rb rename to app/forms/custom_fields/inputs/float.rb index 9a4c796ff965..62e45610b409 100644 --- a/app/forms/projects/custom_fields/inputs/float.rb +++ b/app/forms/custom_fields/inputs/float.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Float < Projects::CustomFields::Inputs::Base::Input +class CustomFields::Inputs::Float < CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/app/forms/projects/custom_fields/inputs/int.rb b/app/forms/custom_fields/inputs/int.rb similarity index 94% rename from app/forms/projects/custom_fields/inputs/int.rb rename to app/forms/custom_fields/inputs/int.rb index e89878088901..ab5fc29e50d9 100644 --- a/app/forms/projects/custom_fields/inputs/int.rb +++ b/app/forms/custom_fields/inputs/int.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Int < Projects::CustomFields::Inputs::Base::Input +class CustomFields::Inputs::Int < CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/app/forms/projects/custom_fields/inputs/multi_select_list.rb b/app/forms/custom_fields/inputs/multi_select_list.rb similarity index 94% rename from app/forms/projects/custom_fields/inputs/multi_select_list.rb rename to app/forms/custom_fields/inputs/multi_select_list.rb index f7acb050b3a4..e71ff69b8732 100644 --- a/app/forms/projects/custom_fields/inputs/multi_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::MultiSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput +class CustomFields::Inputs::MultiSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field diff --git a/app/forms/projects/custom_fields/inputs/multi_user_select_list.rb b/app/forms/custom_fields/inputs/multi_user_select_list.rb similarity index 88% rename from app/forms/projects/custom_fields/inputs/multi_user_select_list.rb rename to app/forms/custom_fields/inputs/multi_user_select_list.rb index d75890f66041..323577ac732a 100644 --- a/app/forms/projects/custom_fields/inputs/multi_user_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_user_select_list.rb @@ -26,8 +26,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::MultiUserSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput - include Projects::CustomFields::Inputs::Base::Autocomplete::UserQueryUtils +class CustomFields::Inputs::MultiUserSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput + include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| # TODO: use user_autocompleter as seen on sharing form instead diff --git a/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb b/app/forms/custom_fields/inputs/multi_version_select_list.rb similarity index 92% rename from app/forms/projects/custom_fields/inputs/multi_version_select_list.rb rename to app/forms/custom_fields/inputs/multi_version_select_list.rb index 8094ce74e708..75ce18ef0ced 100644 --- a/app/forms/projects/custom_fields/inputs/multi_version_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_version_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::MultiVersionSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::MultiValueInput +class CustomFields::Inputs::MultiVersionSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field @@ -34,7 +34,7 @@ class Projects::CustomFields::Inputs::MultiVersionSelectList < Projects::CustomF custom_value_form.hidden(**input_attributes.merge(name: "#{input_attributes[:name]}[]", value:)) custom_value_form.autocompleter(**input_attributes) do |list| - @project.versions.each do |version| + @object.versions.each do |version| list.option( label: version.name, value: version.id, selected: selected?(version) diff --git a/app/forms/projects/custom_fields/inputs/single_select_list.rb b/app/forms/custom_fields/inputs/single_select_list.rb similarity index 94% rename from app/forms/projects/custom_fields/inputs/single_select_list.rb rename to app/forms/custom_fields/inputs/single_select_list.rb index 4e326ac3caa8..907200cb95dd 100644 --- a/app/forms/projects/custom_fields/inputs/single_select_list.rb +++ b/app/forms/custom_fields/inputs/single_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::SingleSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput +class CustomFields::Inputs::SingleSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field diff --git a/app/forms/projects/custom_fields/inputs/single_user_select_list.rb b/app/forms/custom_fields/inputs/single_user_select_list.rb similarity index 86% rename from app/forms/projects/custom_fields/inputs/single_user_select_list.rb rename to app/forms/custom_fields/inputs/single_user_select_list.rb index 4dab874f24d8..ada4dd25ff0e 100644 --- a/app/forms/projects/custom_fields/inputs/single_user_select_list.rb +++ b/app/forms/custom_fields/inputs/single_user_select_list.rb @@ -26,8 +26,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::SingleUserSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput - include Projects::CustomFields::Inputs::Base::Autocomplete::UserQueryUtils +class CustomFields::Inputs::SingleUserSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput + include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| # TODO: use user_autocompleter as seen on sharing form instead @@ -45,6 +45,6 @@ def autocomplete_options end def init_user_ids - [@custom_value.value] + @custom_value.value.present? ? [@custom_value.value] : [] end end diff --git a/app/forms/projects/custom_fields/inputs/single_version_select_list.rb b/app/forms/custom_fields/inputs/single_version_select_list.rb similarity index 91% rename from app/forms/projects/custom_fields/inputs/single_version_select_list.rb rename to app/forms/custom_fields/inputs/single_version_select_list.rb index 8a5b6bb90031..18ebebd03ff2 100644 --- a/app/forms/projects/custom_fields/inputs/single_version_select_list.rb +++ b/app/forms/custom_fields/inputs/single_version_select_list.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::SingleVersionSelectList < Projects::CustomFields::Inputs::Base::Autocomplete::SingleValueInput +class CustomFields::Inputs::SingleVersionSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field @@ -35,7 +35,7 @@ class Projects::CustomFields::Inputs::SingleVersionSelectList < Projects::Custom custom_value_form.autocompleter(**input_attributes) do |list| # TODO: allow-non-open version setting is not yet respected! - @project.versions.each do |version| + @object.versions.each do |version| list.option( label: version.name, value: version.id, selected: selected?(version) diff --git a/app/forms/projects/custom_fields/inputs/string.rb b/app/forms/custom_fields/inputs/string.rb similarity index 93% rename from app/forms/projects/custom_fields/inputs/string.rb rename to app/forms/custom_fields/inputs/string.rb index 0205cf602c9a..8af06d84e6a8 100644 --- a/app/forms/projects/custom_fields/inputs/string.rb +++ b/app/forms/custom_fields/inputs/string.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::String < Projects::CustomFields::Inputs::Base::Input +class CustomFields::Inputs::String < CustomFields::Inputs::Base::Input form do |custom_value_form| custom_value_form.text_field(**input_attributes) end diff --git a/app/forms/projects/custom_fields/inputs/text.rb b/app/forms/custom_fields/inputs/text.rb similarity index 95% rename from app/forms/projects/custom_fields/inputs/text.rb rename to app/forms/custom_fields/inputs/text.rb index c6683305fea0..792c948138f2 100644 --- a/app/forms/projects/custom_fields/inputs/text.rb +++ b/app/forms/custom_fields/inputs/text.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Projects::CustomFields::Inputs::Text < Projects::CustomFields::Inputs::Base::Input +class CustomFields::Inputs::Text < CustomFields::Inputs::Base::Input form do |custom_value_form| # TODO: rich_text_area not working yet # Uncaught DOMException: Failed to execute 'querySelector' on 'Element': '#project_project[new_custom_field_values_attributes][xyz][value]' is not a valid selector. diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb index 8358c9431a25..ef19e8bc6683 100644 --- a/app/forms/projects/custom_fields/form.rb +++ b/app/forms/projects/custom_fields/form.rb @@ -35,37 +35,37 @@ class Form < ApplicationForm end end - def initialize(project:, custom_field_section: nil) + def initialize(project:, custom_field_section: nil, custom_field: nil) super() @project = project @custom_field_section = custom_field_section + @custom_field = custom_field + + if @custom_field_section.present? && @custom_field.present? + raise ArgumentError, + "Either custom_field_section or custom_field must be specified, but not both" + end end private def sorted_custom_fields - # TODO: move to service/model return @custom_fields if @custom_fields.present? - @custom_fields ||= @project.available_custom_fields - - if @custom_field_section.present? - @custom_fields = @custom_fields - .where(custom_field_section_id: @custom_field_section.id) - end - - @custom_fields = @custom_fields.sort_by do |pcf| - [pcf.project_custom_field_section.position, pcf.position_in_custom_field_section] - end + @custom_fields = if @custom_field.present? + [@custom_field] + elsif @custom_field_section.present? + @project.sorted_available_custom_fields_by_section(@custom_field_section) + else + @project.sorted_available_custom_fields + end end def custom_field_input(builder, custom_field) if custom_field.multi_value? - custom_values = @project.custom_values_for_custom_field(id: custom_field.id) - multi_value_custom_field_input(builder, custom_field, custom_values) + multi_value_custom_field_input(builder, custom_field) else - custom_value = @project.custom_value_for(custom_field.id) - single_value_custom_field_input(builder, custom_field, custom_value) + single_value_custom_field_input(builder, custom_field) end end @@ -73,42 +73,45 @@ def custom_field_input(builder, custom_field) # TODOS: # - initial values for user inputs are not displayed # - allow/disallow-non-open version setting is not yet respected in the version selector + # - rich text editor is not yet supported - def single_value_custom_field_input(builder, custom_field, custom_value) - form_args = { custom_field:, custom_value:, project: @project } + def single_value_custom_field_input(builder, custom_field) + custom_value = @project.custom_value_for(custom_field.id) + form_args = { custom_field:, custom_value:, object: @project } case custom_field.field_format when "string" - Projects::CustomFields::Inputs::String.new(builder, **form_args) + CustomFields::Inputs::String.new(builder, **form_args) when "text" - Projects::CustomFields::Inputs::Text.new(builder, **form_args) + CustomFields::Inputs::Text.new(builder, **form_args) when "int" - Projects::CustomFields::Inputs::Int.new(builder, **form_args) + CustomFields::Inputs::Int.new(builder, **form_args) when "float" - Projects::CustomFields::Inputs::Float.new(builder, **form_args) + CustomFields::Inputs::Float.new(builder, **form_args) when "list" - Projects::CustomFields::Inputs::SingleSelectList.new(builder, **form_args) + CustomFields::Inputs::SingleSelectList.new(builder, **form_args) when "date" - Projects::CustomFields::Inputs::Date.new(builder, **form_args) + CustomFields::Inputs::Date.new(builder, **form_args) when "bool" - Projects::CustomFields::Inputs::Bool.new(builder, **form_args) + CustomFields::Inputs::Bool.new(builder, **form_args) when "user" - Projects::CustomFields::Inputs::SingleUserSelectList.new(builder, **form_args) + CustomFields::Inputs::SingleUserSelectList.new(builder, **form_args) when "version" - Projects::CustomFields::Inputs::SingleVersionSelectList.new(builder, **form_args) + CustomFields::Inputs::SingleVersionSelectList.new(builder, **form_args) end end - def multi_value_custom_field_input(builder, custom_field, custom_values) - form_args = { custom_field:, custom_values:, project: @project } + def multi_value_custom_field_input(builder, custom_field) + custom_values = @project.custom_values_for_custom_field(id: custom_field.id) + form_args = { custom_field:, custom_values:, object: @project } case custom_field.field_format when "list" - Projects::CustomFields::Inputs::MultiSelectList.new(builder, **form_args) + CustomFields::Inputs::MultiSelectList.new(builder, **form_args) when "user" - Projects::CustomFields::Inputs::MultiUserSelectList.new(builder, **form_args) + CustomFields::Inputs::MultiUserSelectList.new(builder, **form_args) when "version" - Projects::CustomFields::Inputs::MultiVersionSelectList.new(builder, **form_args) + CustomFields::Inputs::MultiVersionSelectList.new(builder, **form_args) end end end diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index d3cf2f0825e2..321792f33e5d 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -45,6 +45,21 @@ def available_custom_fields .where(id: active_custom_field_ids_of_project) end + def available_custom_fields_by_section(section) + available_custom_fields + .where(custom_field_section_id: section.id) + end + + def sorted_available_custom_fields + available_custom_fields + .sort_by { |pcf| [pcf.project_custom_field_section.position, pcf.position_in_custom_field_section] } + end + + def sorted_available_custom_fields_by_section(section) + available_custom_fields_by_section(section) + .sort_by(&:position_in_custom_field_section) + end + def custom_field_section_ids # we need to check if a project custom field belongs to a specific section when validating # we need a mapping of custom_field_id => custom_field_section_id as we don't want to diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index ea0e6c620721..6f72a75610a6 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -8,7 +8,7 @@ url: project_update_attributes_path(project_id: @project.id, section_id: @project_custom_field_section.id), ) do |f| component_collection do |collection| - collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 500px;")) do + collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, style: "max-height: 500px;")) do f.fields_for(:custom_field_values) do |f| render(Projects::CustomFields::Form.new(f, project: @project, custom_field_section: @project_custom_field_section)) end From 77008c5a0226a8cc8be7576d6f35d657102fc8ee Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 1 Feb 2024 13:50:15 +0700 Subject: [PATCH 049/218] fixed form input name nesting --- .../inputs/base/autocomplete/multi_value_input.rb | 3 +-- .../inputs/base/autocomplete/single_value_input.rb | 3 +-- app/forms/custom_fields/inputs/base/utils.rb | 4 +--- app/forms/custom_fields/inputs/multi_select_list.rb | 6 +++++- app/forms/custom_fields/inputs/multi_version_select_list.rb | 6 +++++- .../sections/edit_dialog_component.html.erb | 4 +--- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb index 505295bb706b..288fab4b56ea 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -48,8 +48,7 @@ def autocomplete_options { multiple: true, decorated: decorated?, - inputId: id, - inputName: name + inputId: id } end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb index b533da6f7a90..d14ccd4dac85 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -48,8 +48,7 @@ def autocomplete_options { multiple: false, decorated: decorated?, - inputId: id, - inputName: name + inputId: id } end diff --git a/app/forms/custom_fields/inputs/base/utils.rb b/app/forms/custom_fields/inputs/base/utils.rb index 0f8f1a4dc43a..9b5ba930cddd 100644 --- a/app/forms/custom_fields/inputs/base/utils.rb +++ b/app/forms/custom_fields/inputs/base/utils.rb @@ -30,7 +30,6 @@ module CustomFields::Inputs::Base::Utils def base_input_attributes { id:, - scope_name_to_model: false, # TODO: get rid of this, should work with scope_name_to_model: true name:, label:, value:, @@ -45,8 +44,7 @@ def id end def name - # TODO: get rid of this, should work with scope_name_to_model: true - "#{@object.class.name.downcase}[custom_field_values][#{@custom_field.id}]" + @custom_field.id.to_s end def label diff --git a/app/forms/custom_fields/inputs/multi_select_list.rb b/app/forms/custom_fields/inputs/multi_select_list.rb index e71ff69b8732..152ce7b0ef35 100644 --- a/app/forms/custom_fields/inputs/multi_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_select_list.rb @@ -31,7 +31,11 @@ class CustomFields::Inputs::MultiSelectList < CustomFields::Inputs::Base::Autoco # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field # which sends blank if autocompleter is cleared - custom_value_form.hidden(**input_attributes.merge(name: "#{input_attributes[:name]}[]", value:)) + custom_value_form.hidden(**input_attributes.merge( + scope_name_to_model: false, + name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + value: + )) custom_value_form.autocompleter(**input_attributes) do |list| @custom_field.custom_options.each do |custom_option| diff --git a/app/forms/custom_fields/inputs/multi_version_select_list.rb b/app/forms/custom_fields/inputs/multi_version_select_list.rb index 75ce18ef0ced..d0c3dd82ef6d 100644 --- a/app/forms/custom_fields/inputs/multi_version_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_version_select_list.rb @@ -31,7 +31,11 @@ class CustomFields::Inputs::MultiVersionSelectList < CustomFields::Inputs::Base: # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field # which sends blank if autocompleter is cleared - custom_value_form.hidden(**input_attributes.merge(name: "#{input_attributes[:name]}[]", value:)) + custom_value_form.hidden(**input_attributes.merge( + scope_name_to_model: false, + name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + value: + )) custom_value_form.autocompleter(**input_attributes) do |list| @object.versions.each do |version| diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index 6f72a75610a6..c183a723f970 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -9,9 +9,7 @@ ) do |f| component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, style: "max-height: 500px;")) do - f.fields_for(:custom_field_values) do |f| - render(Projects::CustomFields::Form.new(f, project: @project, custom_field_section: @project_custom_field_section)) - end + render(Projects::CustomFields::Form.new(f, project: @project, custom_field_section: @project_custom_field_section)) end collection.with_component(Primer::Alpha::Dialog::Footer.new) do component_collection do |footer_collection| From 670dbfa21822ebdd65c0b09b1aee2257d7379284 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 1 Feb 2024 15:02:05 +0700 Subject: [PATCH 050/218] fixed dialog specs and boolean input --- .../inputs/base/autocomplete/multi_value_input.rb | 3 +-- .../inputs/base/autocomplete/single_value_input.rb | 3 +-- app/forms/custom_fields/inputs/base/utils.rb | 5 ----- app/forms/custom_fields/inputs/bool.rb | 2 +- .../components/projects/project_custom_fields/edit_dialog.rb | 4 +++- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb index 288fab4b56ea..fa46850be4c9 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -47,8 +47,7 @@ def input_attributes def autocomplete_options { multiple: true, - decorated: decorated?, - inputId: id + decorated: decorated? } end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb index d14ccd4dac85..5472cecb9cc2 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -47,8 +47,7 @@ def input_attributes def autocomplete_options { multiple: false, - decorated: decorated?, - inputId: id + decorated: decorated? } end diff --git a/app/forms/custom_fields/inputs/base/utils.rb b/app/forms/custom_fields/inputs/base/utils.rb index 9b5ba930cddd..84c3ea806bda 100644 --- a/app/forms/custom_fields/inputs/base/utils.rb +++ b/app/forms/custom_fields/inputs/base/utils.rb @@ -29,7 +29,6 @@ module CustomFields::Inputs::Base::Utils def base_input_attributes { - id:, name:, label:, value:, @@ -39,10 +38,6 @@ def base_input_attributes } end - def id - "custom_field_#{@custom_field.id}" - end - def name @custom_field.id.to_s end diff --git a/app/forms/custom_fields/inputs/bool.rb b/app/forms/custom_fields/inputs/bool.rb index c3e6b1e22373..69ee007427fb 100644 --- a/app/forms/custom_fields/inputs/bool.rb +++ b/app/forms/custom_fields/inputs/bool.rb @@ -35,7 +35,7 @@ def input_attributes super.merge({ value: "1", unchecked_value: "0", - checked: @custom_field_value&.typed_value == true || @custom_field.default_value == true + checked: @custom_value&.typed_value == true }) end end diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb index 297a69f1966f..a731896bd6ad 100644 --- a/spec/support/components/projects/project_custom_fields/edit_dialog.rb +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -96,7 +96,9 @@ def expect_async_content_loaded ### def input_containers - page.all('.op-project-custom-field-input-container') + within '.Overlay-body > .FormControl-spacingWrapper' do + page.all('.FormControl-spacingWrapper') + end end def within_custom_field_input_container(custom_field, &) From 3de8c221149ed758acb4a93773315746b4e71b89 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 1 Feb 2024 15:26:57 +0700 Subject: [PATCH 051/218] support rich text input --- app/forms/custom_fields/inputs/text.rb | 2 +- .../overview_page/dialog_spec.rb | 36 +++++++++++++++++-- .../overview_page/shared_context.rb | 2 +- .../primerized/editor_form_field.rb | 32 +++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 spec/support/form_fields/primerized/editor_form_field.rb diff --git a/app/forms/custom_fields/inputs/text.rb b/app/forms/custom_fields/inputs/text.rb index 792c948138f2..d0645a41956c 100644 --- a/app/forms/custom_fields/inputs/text.rb +++ b/app/forms/custom_fields/inputs/text.rb @@ -33,6 +33,6 @@ class CustomFields::Inputs::Text < CustomFields::Inputs::Base::Input # --> rich_text_area is not using the configured id, which is not scoped to model via base_config # --> ids with '[' ']' are not valid selectors # using simple text area for now - custom_value_form.text_area(**input_attributes) + custom_value_form.rich_text_area(**input_attributes) end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb index 1e54e7685aea..68dc72be600e 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb @@ -245,6 +245,37 @@ end end + shared_examples 'a rich text custom field input' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_initial_value) + end + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_blank_value) + end + end + + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(default_value) + end + end + end + describe 'with boolean CF' do let(:custom_field) { boolean_project_custom_field } let(:default_value) { false } @@ -292,11 +323,12 @@ describe 'with text CF' do let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } let(:default_value) { 'Default value' } let(:expected_blank_value) { '' } - let(:expected_initial_value) { "Lorem\nipsum" } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? - it_behaves_like 'a custom field input' + it_behaves_like 'a rich text custom field input' end end diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb index 5af4fb30494c..3c50c90a4524 100644 --- a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -131,7 +131,7 @@ field = create(:text_project_custom_field, projects: [project], name: 'Text field', project_custom_field_section: section_for_input_fields) - create(:custom_value, customized: project, custom_field: field, value: "Lorem\nipsum") + create(:custom_value, customized: project, custom_field: field, value: "Lorem\n\nipsum") field end diff --git a/spec/support/form_fields/primerized/editor_form_field.rb b/spec/support/form_fields/primerized/editor_form_field.rb new file mode 100644 index 000000000000..9bbb23e2cb6b --- /dev/null +++ b/spec/support/form_fields/primerized/editor_form_field.rb @@ -0,0 +1,32 @@ +require_relative 'form_field' + +module FormFields + module Primerized + class EditorFormField < FormField + attr_reader :editor + + delegate :expect_value, to: :editor + + def initialize(property, selector: nil) + super + + @editor = ::Components::WysiwygEditor.new(selector) + end + + def expect_visible + !!editor.container + end + + ## + # Set or select the given value. + # For fields of type select, will check for an option with that value. + def set_value(content) + editor.set_markdown(content) + end + + def input_element + editor.editor_element + end + end + end +end From 9e6a72594d69855eaf07e5e9c7bf73c087de4707 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 1 Feb 2024 16:30:28 +0700 Subject: [PATCH 052/218] fixed dialog validation specs --- .../overview_page/dialog_spec.rb | 10 ++++++---- .../form_fields/primerized/editor_form_field.rb | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb index 68dc72be600e..52b80099a46f 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb @@ -751,7 +751,7 @@ overview_page.open_edit_dialog_for_section(section) - dialog.submit(wait_until_done: true) + dialog.submit field.expect_error(I18n.t('activerecord.errors.messages.blank')) end @@ -873,7 +873,7 @@ describe 'with text CF' do let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } it_behaves_like 'a custom field input' @@ -882,7 +882,9 @@ overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: 'Foo') + field.set_value('Foooo') + + page.save_screenshot('screenshot.png') dialog.submit @@ -894,7 +896,7 @@ overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: 'Fo') + field.set_value('Fo') dialog.submit diff --git a/spec/support/form_fields/primerized/editor_form_field.rb b/spec/support/form_fields/primerized/editor_form_field.rb index 9bbb23e2cb6b..6f6b59705642 100644 --- a/spec/support/form_fields/primerized/editor_form_field.rb +++ b/spec/support/form_fields/primerized/editor_form_field.rb @@ -13,8 +13,9 @@ def initialize(property, selector: nil) @editor = ::Components::WysiwygEditor.new(selector) end - def expect_visible - !!editor.container + def field_container + augmented_textarea = page.find("[data-textarea-selector='\"#project_custom_field_values_#{property.id}\"']") + augmented_textarea.first(:xpath, ".//..") end ## @@ -27,6 +28,18 @@ def set_value(content) def input_element editor.editor_element end + + # expectations + + def expect_visible + !!editor.container + end + + def expect_error(string = nil) + sleep 2 # quick fix for stale element error + expect(field_container).to have_css('.FormControl-inlineValidation') + expect(field_container).to have_content(string) if string + end end end end From c98ddc1a927286fcba8a2741cf8970da9a31e8a6 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 5 Feb 2024 14:40:00 +0700 Subject: [PATCH 053/218] refactored custom value update to contract/service based approach --- app/contracts/projects/base_contract.rb | 4 +++ .../projects/acts_as_customizable_patches.rb | 13 +++---- .../overviews/overviews_controller.rb | 34 +++++++++++-------- .../overview_page/dialog_spec.rb | 2 -- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb index 84bfde71333e..4b64a68872eb 100644 --- a/app/contracts/projects/base_contract.rb +++ b/app/contracts/projects/base_contract.rb @@ -50,6 +50,10 @@ class BaseContract < ::ModelContract validate_templated_set_by_admin end + attribute :touched_custom_field_section_id + # `touched_custom_field_section_id` used in Projects::ActsAsCustomizablePatches in order to + # only validate custom fields of the touched section + validate :validate_user_allowed_to_manage def assignable_parents diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 321792f33e5d..81963e4bb15b 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -29,6 +29,8 @@ module Projects::ActsAsCustomizablePatches extend ActiveSupport::Concern + attr_accessor :touched_custom_field_section_id + included do def active_custom_field_ids_of_project @active_custom_field_ids_of_project ||= ProjectCustomFieldProjectMapping @@ -85,18 +87,11 @@ def validate_custom_values end def of_touched_custom_field_section?(custom_value) - if @touched_section_id.present? - custom_field_section_ids[custom_value.custom_field_id] == @touched_section_id + if touched_custom_field_section_id.present? + custom_field_section_ids[custom_value.custom_field_id] == touched_custom_field_section_id else true # validate all custom values if no specific section was marked as touched via `update_custom_field_values_of_section` end end - - def update_custom_field_values_of_section(section, params) - # we need to set the touched section id to validate only the custom values of the touched section - @touched_section_id = section.id - - update(params) - end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index b0e780fd1a71..fc316d16c804 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -34,20 +34,26 @@ def attribute_section_dialog end def update_attributes - @section = find_project_custom_field_section - - # TODO: transform to contract/service-based approach with permission checks - @project.update_custom_field_values_of_section(@section, permitted_params.project) - - has_errors = @project.errors.any? - - if has_errors - handle_errors - else + section = find_project_custom_field_section + + service_call = ::Projects::UpdateService + .new( + user: current_user, + model: @project + ) + .call( + permitted_params.project.merge( + touched_custom_field_section_id: section.id + ) + ) + + if service_call.success? update_sidebar_component + else + handle_errors(service_call.result, section) end - respond_to_with_turbo_streams(status: has_errors ? :unprocessable_entity : :ok) + respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) end def jump_to_project_menu_item @@ -85,11 +91,11 @@ def find_project_custom_field_section ProjectCustomFieldSection.find(params[:section_id]) end - def handle_errors + def handle_errors(project_with_errors, section) update_via_turbo_stream( component: ProjectCustomFields::Sections::EditDialogComponent.new( - project: @project, - project_custom_field_section: @section + project: project_with_errors, + project_custom_field_section: section ) ) end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb index 52b80099a46f..2e48e099b693 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb @@ -884,8 +884,6 @@ field.set_value('Foooo') - page.save_screenshot('screenshot.png') - dialog.submit field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) From 0648e5d1b794748728599ca108c78dd1a23e6405 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 5 Feb 2024 15:10:28 +0700 Subject: [PATCH 054/218] add permission checks for overview page requests and added related specs --- .../overviews/overviews_controller.rb | 3 +- modules/overviews/lib/overviews/engine.rb | 16 ++++--- .../manage_project_custom_values.rb | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 spec/permissions/manage_project_custom_values.rb diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index fc316d16c804..e4261ac40721 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -2,6 +2,7 @@ module ::Overviews class OverviewsController < ::Grids::BaseInProjectController include OpTurbo::ComponentStream + before_action :authorize before_action :jump_to_project_menu_item before_action :check_project_attributes_feature_enabled, only: %i[attributes_sidebar attribute_section_dialog update_attributes] @@ -9,7 +10,6 @@ class OverviewsController < ::Grids::BaseInProjectController menu_item :overview def attributes_sidebar - # TODO: check permissions render( ProjectCustomFields::SidebarComponent.new( project: @project, @@ -21,7 +21,6 @@ def attributes_sidebar end def attribute_section_dialog - # TODO: check permissions @section = find_project_custom_field_section render( diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index 646ce96ae772..5213afab5ad3 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -48,23 +48,27 @@ class Engine < ::Rails::Engine .controller_actions .push( 'overviews/overviews/show', - 'overviews/overviews/attributes_sidebar', + 'overviews/overviews/attributes_sidebar' + ) + + OpenProject::AccessControl.permission(:edit_project) + .controller_actions + .push( 'overviews/overviews/attribute_section_dialog', 'overviews/overviews/update_attributes' ) OpenProject::AccessControl.permission(:view_work_packages) - .controller_actions - .push('overviews/overviews/show') + .controller_actions + .push('overviews/overviews/show') OpenProject::AccessControl.map do |ac_map| ac_map.project_module nil do |map| map.permission :manage_overview, { 'overviews/overviews': [ - 'show', 'attributes_sidebar', 'attribute_section_dialog', 'update_attributes' - ] - }, + 'show' + ] }, permissible_on: :project, require: :member end diff --git a/spec/permissions/manage_project_custom_values.rb b/spec/permissions/manage_project_custom_values.rb new file mode 100644 index 000000000000..c045bb47617d --- /dev/null +++ b/spec/permissions/manage_project_custom_values.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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' +require File.expand_path('../support/permission_specs', __dir__) + +RSpec.describe Overviews::OverviewsController, 'manage_project_custom_values permission', + type: :controller do + include PermissionSpecs + + # render sidebar on project overview page with view_project permission + # TODO: prevents calling overviews/overviews#attributes_sidebar when not having the permission view_project (FAILED - 1) + check_permission_required_for('overviews/overviews#attributes_sidebar', :view_project) + + # render dialog with inputs for editing project attributes with edit_project permission + check_permission_required_for('overviews/overviews#attribute_section_dialog', :edit_project) + + # update project attributes with edit_project permission, deeper permission check via contract in place + check_permission_required_for('overviews/overviews#update_attributes', :edit_project) +end From d568b5c783b9c492de6419200f0fdd7ab4012e07 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 6 Feb 2024 11:04:17 +0700 Subject: [PATCH 055/218] refactoring and name fixes --- app/contracts/projects/base_contract.rb | 4 +- .../projects/acts_as_customizable_patches.rb | 22 ++++++---- .../features/overview/overview.component.ts | 4 +- .../sections/edit_dialog_component.html.erb | 6 +-- .../sections/show_component.html.erb | 4 +- .../sidebar_component.html.erb | 8 ++-- .../sidebar_component.rb | 11 +++-- .../overviews/overviews_controller.rb | 42 ++++++------------- modules/overviews/config/routes.rb | 12 +++--- modules/overviews/lib/overviews/engine.rb | 6 +-- .../overview_page/sidebar_spec.rb | 7 ++-- ...b => manage_project_custom_values_spec.rb} | 8 ++-- .../project_custom_fields/edit_dialog.rb | 2 +- spec/support/pages/projects/show.rb | 4 +- 14 files changed, 66 insertions(+), 74 deletions(-) rename spec/permissions/{manage_project_custom_values.rb => manage_project_custom_values_spec.rb} (79%) diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb index 4b64a68872eb..49e55622adc8 100644 --- a/app/contracts/projects/base_contract.rb +++ b/app/contracts/projects/base_contract.rb @@ -50,8 +50,8 @@ class BaseContract < ::ModelContract validate_templated_set_by_admin end - attribute :touched_custom_field_section_id - # `touched_custom_field_section_id` used in Projects::ActsAsCustomizablePatches in order to + attribute :limit_custom_fields_validation_to_section_id + # `limit_custom_fields_validation_to_section_id` used in Projects::ActsAsCustomizablePatches in order to # only validate custom fields of the touched section validate :validate_user_allowed_to_manage diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 81963e4bb15b..6e0fb7a015ce 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -29,7 +29,10 @@ module Projects::ActsAsCustomizablePatches extend ActiveSupport::Concern - attr_accessor :touched_custom_field_section_id + attr_accessor :limit_custom_fields_validation_to_section_id + + # attr_accessor :limit_custom_fields_validation_to_field_id + # not needed for now, but might be relevant if we want to have edit dialogs just for one custom field included do def active_custom_field_ids_of_project @@ -47,6 +50,11 @@ def available_custom_fields .where(id: active_custom_field_ids_of_project) end + def available_project_custom_fields_grouped_by_section + sorted_available_custom_fields + .group_by(&:custom_field_section_id) + end + def available_custom_fields_by_section(section) available_custom_fields .where(custom_field_section_id: section.id) @@ -75,22 +83,22 @@ def custom_field_section_ids def validate_custom_values # overrides acts_as_customizable - # validate custom values only of the touched section + # validate custom values only of a specified section # instead of validating ALL custom values like done in acts_as_customizable set_default_values! if new_record? custom_field_values - .select { |custom_value| of_touched_custom_field_section?(custom_value) } + .select { |custom_value| of_specified_custom_field_section?(custom_value) } .reject(&:marked_for_destruction?) .select(&:invalid?) .each { |custom_value| add_custom_value_errors! custom_value } end - def of_touched_custom_field_section?(custom_value) - if touched_custom_field_section_id.present? - custom_field_section_ids[custom_value.custom_field_id] == touched_custom_field_section_id + def of_specified_custom_field_section?(custom_value) + if limit_custom_fields_validation_to_section_id.present? + custom_field_section_ids[custom_value.custom_field_id] == limit_custom_fields_validation_to_section_id else - true # validate all custom values if no specific section was marked as touched via `update_custom_field_values_of_section` + true # validate all custom values if no specific section was specified end end end diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index d0de3b2f2b8f..fcfdbe3ecdc1 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -18,11 +18,11 @@ export class OverviewComponent extends GridPageComponent { } protected turboFrameSidebarSrc():string { - return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier!}/attributes_sidebar`; + return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier!}/project_custom_fields_sidebar`; } protected turboFrameSidebarId():string { - return "project-attributes-sidebar"; + return "project-custom-fields-sidebar"; } protected gridScopePath():string { diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index c183a723f970..42078c509cc5 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -1,11 +1,11 @@ <%= - content_tag("turbo-frame", id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}-frame") do + content_tag("turbo-frame", id: "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}-frame") do component_wrapper(data: { qa_selector: 'async-dialog-content' }) do primer_form_with( model: @project, method: :put, - url: project_update_attributes_path(project_id: @project.id, section_id: @project_custom_field_section.id), + url: update_project_custom_values_path(project_id: @project.id, section_id: @project_custom_field_section.id), ) do |f| component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, style: "max-height: 500px;")) do @@ -15,7 +15,7 @@ component_collection do |footer_collection| footer_collection.with_component(Primer::ButtonComponent.new( data: { - 'close-dialog-id': "edit-project-attributes-dialog-#{@project_custom_field_section.id}" + 'close-dialog-id': "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" } )) do t("button_cancel") diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 3a573264c343..0d114a622a62 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -11,8 +11,8 @@ heading.with_column do render(OpTurbo::OpPrimer::AsyncDialogComponent.new( - id: "edit-project-attributes-dialog-#{@project_custom_field_section.id}", - src: project_attribute_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), + id: "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}", + src: project_custom_field_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), size: :medium_portrait, title: @project_custom_field_section.name, button_icon: :pencil, diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb index ebbe0447486c..dabcd20a885e 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -1,9 +1,9 @@ <%= - content_tag("turbo-frame", id: "project-attributes-sidebar") do + content_tag("turbo-frame", id: "project-custom-fields-sidebar") do component_wrapper do - if @active_project_custom_fields_grouped_by_section.any? - flex_layout(data: { qa_selector: "project-attributes-sidebar-async-content" }) do |sections_container| - @active_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| + if available_project_custom_fields_grouped_by_section.any? + flex_layout(data: { qa_selector: "project-custom-fields-sidebar-async-content" }) do |sections_container| + available_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| sections_container.with_row(mb: 3) do render(ProjectCustomFields::Sections::ShowComponent.new( project: @project, diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.rb b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb index 207f065c038e..2b6731ca7625 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb @@ -32,18 +32,21 @@ class SidebarComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:, project_custom_field_sections:, active_project_custom_fields_grouped_by_section:) + def initialize(project:, eager_loaded_project_custom_field_sections:) super @project = project - @project_custom_field_sections = project_custom_field_sections - @active_project_custom_fields_grouped_by_section = active_project_custom_fields_grouped_by_section + @eager_loaded_project_custom_field_sections = eager_loaded_project_custom_field_sections end private def get_eager_loaded_project_custom_field_section(project_custom_field_section_id) - @project_custom_field_sections.find { |pcfs| pcfs.id == project_custom_field_section_id } + @eager_loaded_project_custom_field_sections.find { |pcfs| pcfs.id == project_custom_field_section_id } + end + + def available_project_custom_fields_grouped_by_section + @available_project_custom_fields_grouped_by_section ||= @project.available_project_custom_fields_grouped_by_section end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index e4261ac40721..03f2327f92f4 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -5,34 +5,31 @@ class OverviewsController < ::Grids::BaseInProjectController before_action :authorize before_action :jump_to_project_menu_item before_action :check_project_attributes_feature_enabled, - only: %i[attributes_sidebar attribute_section_dialog update_attributes] + only: %i[project_custom_fields_sidebar project_custom_field_section_dialog update_project_custom_values] menu_item :overview - def attributes_sidebar + def project_custom_fields_sidebar render( ProjectCustomFields::SidebarComponent.new( project: @project, - project_custom_field_sections: ProjectCustomFieldSection.all, - active_project_custom_fields_grouped_by_section: + eager_loaded_project_custom_field_sections: ), layout: false ) end - def attribute_section_dialog - @section = find_project_custom_field_section - + def project_custom_field_section_dialog render( ProjectCustomFields::Sections::EditDialogComponent.new( project: @project, - project_custom_field_section: @section + project_custom_field_section: find_project_custom_field_section ), layout: false ) end - def update_attributes + def update_project_custom_values section = find_project_custom_field_section service_call = ::Projects::UpdateService @@ -42,7 +39,7 @@ def update_attributes ) .call( permitted_params.project.merge( - touched_custom_field_section_id: section.id + limit_custom_fields_validation_to_section_id: section.id ) ) @@ -68,24 +65,6 @@ def check_project_attributes_feature_enabled render_404 unless OpenProject::FeatureDecisions.project_attributes_active? end - def active_project_custom_fields_grouped_by_section - # TODO: move to service/model - active_custom_field_ids_of_project = ProjectCustomFieldProjectMapping - .where(project_id: @project.id) - .pluck(:custom_field_id) - - ProjectCustomField - .includes(:project_custom_field_section) - .where(id: active_custom_field_ids_of_project) - .sort_by { |pcf| pcf.project_custom_field_section.position } - .group_by(&:custom_field_section_id) - end - - def active_project_custom_fields_of_section(section_id) - active_project_custom_fields_grouped_by_section[section_id] - .sort_by(&:position_in_custom_field_section) - end - def find_project_custom_field_section ProjectCustomFieldSection.find(params[:section_id]) end @@ -103,10 +82,13 @@ def update_sidebar_component update_via_turbo_stream( component: ProjectCustomFields::SidebarComponent.new( project: @project, - project_custom_field_sections: ProjectCustomFieldSection.all, - active_project_custom_fields_grouped_by_section: + eager_loaded_project_custom_field_sections: ) ) end + + def eager_loaded_project_custom_field_sections + ProjectCustomFieldSection.all.to_a + end end end diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index fb1db6b3c34f..45d0829e8811 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -1,11 +1,11 @@ OpenProject::Application.routes.draw do constraints(project_id: Regexp.new("(?!(#{Project::RESERVED_IDENTIFIERS.join('|')})$)(\\w|-)+")) do get 'projects/:project_id', to: "overviews/overviews#show", as: :project_overview, format: :html - get 'projects/:project_id/attributes_sidebar', to: "overviews/overviews#attributes_sidebar", as: :project_attributes_sidebar, - format: :html - get 'projects/:project_id/attribute_section_dialog/:section_id', to: "overviews/overviews#attribute_section_dialog", - as: :project_attribute_section_dialog, format: :html - put 'projects/:project_id/attributes/:section_id', to: "overviews/overviews#update_attributes", as: :project_update_attributes, - format: :html + get 'projects/:project_id/project_custom_fields_sidebar', to: "overviews/overviews#project_custom_fields_sidebar", as: :project_custom_fields_sidebar, + format: :html + get 'projects/:project_id/project_custom_field_section_dialog/:section_id', to: "overviews/overviews#project_custom_field_section_dialog", + as: :project_custom_field_section_dialog, format: :html + put 'projects/:project_id/update_project_custom_values/:section_id', to: "overviews/overviews#update_project_custom_values", as: :update_project_custom_values, + format: :html end end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index 5213afab5ad3..c738f08b13a9 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -48,14 +48,14 @@ class Engine < ::Rails::Engine .controller_actions .push( 'overviews/overviews/show', - 'overviews/overviews/attributes_sidebar' + 'overviews/overviews/project_custom_fields_sidebar' ) OpenProject::AccessControl.permission(:edit_project) .controller_actions .push( - 'overviews/overviews/attribute_section_dialog', - 'overviews/overviews/update_attributes' + 'overviews/overviews/project_custom_field_section_dialog', + 'overviews/overviews/update_project_custom_values' ) OpenProject::AccessControl.permission(:view_work_packages) diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 03c0b5667e06..eed3c401f36e 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -28,12 +28,11 @@ require 'spec_helper' require_relative 'shared_context' -require_relative 'overview_page' RSpec.describe 'Show project custom fields on project overview page', :js, :with_cuprite do include_context 'with seeded projects, members and project custom fields' - let(:overview_page) { OverviewPage.new(project) } + let(:overview_page) { Pages::Projects::Show.new(project) } before do login_as admin @@ -44,7 +43,7 @@ overview_page.visit_page within '.op-grid-page' do - expect(page).to have_no_css('#project-attributes-sidebar') + expect(page).to have_no_css('#project-custom-fields-sidebar') end end end @@ -54,7 +53,7 @@ overview_page.visit_page within '.op-grid-page' do - expect(page).to have_css('#project-attributes-sidebar') + expect(page).to have_css('#project-custom-fields-sidebar') end end diff --git a/spec/permissions/manage_project_custom_values.rb b/spec/permissions/manage_project_custom_values_spec.rb similarity index 79% rename from spec/permissions/manage_project_custom_values.rb rename to spec/permissions/manage_project_custom_values_spec.rb index c045bb47617d..3791db3cfe96 100644 --- a/spec/permissions/manage_project_custom_values.rb +++ b/spec/permissions/manage_project_custom_values_spec.rb @@ -34,12 +34,12 @@ include PermissionSpecs # render sidebar on project overview page with view_project permission - # TODO: prevents calling overviews/overviews#attributes_sidebar when not having the permission view_project (FAILED - 1) - check_permission_required_for('overviews/overviews#attributes_sidebar', :view_project) + # TODO: prevents calling overviews/overviews#project_custom_fields_sidebar when not having the permission view_project (FAILED - 1) + check_permission_required_for('overviews/overviews#project_custom_fields_sidebar', :view_project) # render dialog with inputs for editing project attributes with edit_project permission - check_permission_required_for('overviews/overviews#attribute_section_dialog', :edit_project) + check_permission_required_for('overviews/overviews#project_custom_field_section_dialog', :edit_project) # update project attributes with edit_project permission, deeper permission check via contract in place - check_permission_required_for('overviews/overviews#update_attributes', :edit_project) + check_permission_required_for('overviews/overviews#update_project_custom_values', :edit_project) end diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb index a731896bd6ad..26fb876e4a24 100644 --- a/spec/support/components/projects/project_custom_fields/edit_dialog.rb +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -46,7 +46,7 @@ def initialize(project, project_custom_field_section) end def dialog_css_selector - "modal-dialog#edit-project-attributes-dialog-#{@project_custom_field_section.id}" + "modal-dialog#edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" end def async_content_container_css_selector diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index a2c3cac9b147..be5c23304d1a 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -56,8 +56,8 @@ def visit_page end def within_async_loaded_sidebar(&) - within '#project-attributes-sidebar' do - expect(page).to have_css("[data-qa-selector='project-attributes-sidebar-async-content']") + within '#project-custom-fields-sidebar' do + expect(page).to have_css("[data-qa-selector='project-custom-fields-sidebar-async-content']") yield end end From 56af7b687c2bef3880d8a9fb4a1ea50bc7267cc6 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 6 Feb 2024 11:33:20 +0700 Subject: [PATCH 056/218] added basic permission spec for project custom field mapping UI --- ...nage_project_custom_field_mappings_spec.rb | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 spec/permissions/manage_project_custom_field_mappings_spec.rb diff --git a/spec/permissions/manage_project_custom_field_mappings_spec.rb b/spec/permissions/manage_project_custom_field_mappings_spec.rb new file mode 100644 index 000000000000..5920cf562918 --- /dev/null +++ b/spec/permissions/manage_project_custom_field_mappings_spec.rb @@ -0,0 +1,40 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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' +require File.expand_path('../support/permission_specs', __dir__) + +RSpec.describe Projects::Settings::ProjectCustomFieldsController, 'manage_project_custom_field mappings permission', + type: :controller do + include PermissionSpecs + + check_permission_required_for('projects/settings/project_custom_fields#show', :select_project_custom_fields) + check_permission_required_for('projects/settings/project_custom_fields#toggle', :select_project_custom_fields) + check_permission_required_for('projects/settings/project_custom_fields#enable_all_of_section', :select_project_custom_fields) + check_permission_required_for('projects/settings/project_custom_fields#disable_all_of_section', :select_project_custom_fields) +end From dcde93a3721a5a8dec3a7f2892d2d5b0fff73505 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 6 Feb 2024 15:24:09 +0700 Subject: [PATCH 057/218] refactored project custom field mapping controller --- .../custom_field_row_component.rb | 8 +- .../show_component.html.erb | 12 ++- .../base_contract.rb | 42 ++++++++ .../update_contract.rb | 32 ++++++ .../project_custom_fields_controller.rb | 81 +++++++------- app/models/permitted_params.rb | 10 ++ .../bulk_edit_service.rb | 102 ++++++++++++++++++ .../set_attributes_service.rb | 32 ++++++ .../toggle_service.rb | 62 +++++++++++ 9 files changed, 336 insertions(+), 45 deletions(-) create mode 100644 app/contracts/project_custom_field_project_mappings/base_contract.rb create mode 100644 app/contracts/project_custom_field_project_mappings/update_contract.rb create mode 100644 app/services/project_custom_field_project_mappings/bulk_edit_service.rb create mode 100644 app/services/project_custom_field_project_mappings/set_attributes_service.rb create mode 100644 app/services/project_custom_field_project_mappings/toggle_service.rb diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 1b78fa474b06..4139e73509a6 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -57,8 +57,12 @@ def active_in_project? def toggle_button render(Primer::Beta::Button.new( tag: :a, - href: toggle_project_settings_project_custom_fields_path(project_id: @project.id, - project_custom_field_id: @project_custom_field.id), + href: toggle_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_id: @project_custom_field.id + } + ), scheme: :invisible, data: { 'turbo-method': :put, 'turbo-stream': true, qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" } diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index db840362ceb8..78f28405247e 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -17,8 +17,10 @@ render(Primer::Beta::Button.new( tag: :a, href: enable_all_of_section_project_settings_project_custom_fields_path( - project_id: @project.id, - project_custom_field_section_id: @project_custom_field_section.id + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_section_id: @project_custom_field_section.id + } ), scheme: :invisible, font_weight: :bold, @@ -34,8 +36,10 @@ render(Primer::Beta::Button.new( tag: :a, href: disable_all_of_section_project_settings_project_custom_fields_path( - project_id: @project.id, - project_custom_field_section_id: @project_custom_field_section.id + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_section_id: @project_custom_field_section.id + } ), scheme: :invisible, font_weight: :bold, diff --git a/app/contracts/project_custom_field_project_mappings/base_contract.rb b/app/contracts/project_custom_field_project_mappings/base_contract.rb new file mode 100644 index 000000000000..aa362cde087b --- /dev/null +++ b/app/contracts/project_custom_field_project_mappings/base_contract.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings + class BaseContract < ::ModelContract + attribute :project_id + attribute :custom_field_id + + validate :validate_has_select_project_custom_fields_permission + + def validate_has_select_project_custom_fields_permission + return if user.allowed_in_project?(:select_project_custom_fields, model.project) + + errors.add :base, :error_unauthorized + end + end +end diff --git a/app/contracts/project_custom_field_project_mappings/update_contract.rb b/app/contracts/project_custom_field_project_mappings/update_contract.rb new file mode 100644 index 000000000000..66140ecdf988 --- /dev/null +++ b/app/contracts/project_custom_field_project_mappings/update_contract.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings + class UpdateContract < BaseContract + end +end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index cd71cfc6f455..d5c64d2349bc 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -37,47 +37,51 @@ class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsCont before_action :eager_load_project_custom_field_project_mappings, only: %i[show toggle enable_all_of_section disable_all_of_section] + before_action :set_project_custom_field, only: %i[toggle] + before_action :set_project_custom_field_section, only: %i[enable_all_of_section disable_all_of_section] + def show; end def toggle - # TODO: use service instead - @project_custom_field = ProjectCustomField.find(params[:project_custom_field_id]) + call = ProjectCustomFieldProjectMappings::ToggleService + .new(user: current_user) + .call(permitted_params.project_custom_field_project_mapping) - mapping = ProjectCustomFieldProjectMapping.find_or_initialize_by( - project_id: @project.id, - custom_field_id: @project_custom_field.id - ) + if call.success? + eager_load_project_custom_field_project_mappings # reload mappings - # toggle mapping - if mapping.persisted? - mapping.destroy! + update_custom_field_row_via_turbo_stream else - mapping.save! + # TODO: handle error end - eager_load_project_custom_field_project_mappings # reload mappings - - update_custom_field_row_via_turbo_stream - respond_with_turbo_streams end def enable_all_of_section - bulk_edit_mappings_per_section(params[:project_custom_field_section_id], :enable) + call = bulk_edit_service.call(action: :enable) - eager_load_project_custom_field_project_mappings # reload mappings + if call.success? + eager_load_project_custom_field_project_mappings # reload mappings - update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + else + # TODO: handle error + end respond_with_turbo_streams end def disable_all_of_section - bulk_edit_mappings_per_section(params[:project_custom_field_section_id], :disable) + call = bulk_edit_service.call(action: :disable) - eager_load_project_custom_field_project_mappings # reload mappings + if call.success? + eager_load_project_custom_field_project_mappings # reload mappings - update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + else + # TODO: handle error + end respond_with_turbo_streams end @@ -101,26 +105,25 @@ def eager_load_project_custom_field_project_mappings .to_a end - def bulk_edit_mappings_per_section(section_id, action = :enable) - # TODO: use service instead - section = ProjectCustomFieldSection.find(section_id) + def set_project_custom_field + # required for component rerenderings + @project_custom_field = ProjectCustomField.find( + permitted_params.project_custom_field_project_mapping[:custom_field_id] + ) + end - # TODO: refactor this to use a single database query - section.custom_fields.each do |pcf| - mapping = ProjectCustomFieldProjectMapping.find_or_initialize_by( - project_id: @project.id, - custom_field_id: pcf.id - ) + def set_project_custom_field_section + @project_custom_field_section = ProjectCustomFieldSection.find( + permitted_params.project_custom_field_project_mapping[:custom_field_section_id] + ) + end - if action == :enable - unless mapping.persisted? - mapping.save! - end - elsif action == :disable - if mapping.persisted? - mapping.destroy! - end - end - end + def bulk_edit_service + ProjectCustomFieldProjectMappings::BulkEditService + .new( + user: current_user, + project: @project, + project_custom_field_section: @project_custom_field_section + ) end end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 28487fdce673..80229c6567fb 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -288,6 +288,11 @@ def project whitelist.merge(custom_field_values(:project)) end + def project_custom_field_project_mapping + params.require(:project_custom_field_project_mapping) + .permit(*self.class.permitted_attributes[:project_custom_field_project_mapping]) + end + def news params.require(:news).permit(:title, :summary, :description) end @@ -552,6 +557,11 @@ def self.permitted_attributes :name, { type_ids: [] } ], + project_custom_field_project_mapping: %i( + project_id + custom_field_id + custom_field_section_id + ), query: %i( name display_sums diff --git a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb b/app/services/project_custom_field_project_mappings/bulk_edit_service.rb new file mode 100644 index 000000000000..e504b8aa0922 --- /dev/null +++ b/app/services/project_custom_field_project_mappings/bulk_edit_service.rb @@ -0,0 +1,102 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings + class BulkEditService < ::BaseServices::BaseCallable + def initialize(user:, project:, project_custom_field_section:) + super() + @user = user + @project = project + @project_custom_field_section = project_custom_field_section + end + + def perform(params) + service_call = validate_permissions + service_call = perform_bulk_edit(service_call, params) if service_call.success? + + service_call + end + + def validate_permissions + if @user.allowed_in_project?(:select_project_custom_fields, @project) + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def perform_bulk_edit(service_call, params) + action = params[:action] + custom_field_ids = fetch_custom_field_ids + + begin + case action + when :enable + enable_custom_fields(custom_field_ids) + when :disable + disable_custom_fields(custom_field_ids) + end + rescue StandardError => e + service_call.success = false + service_call.errors = e.message + end + + service_call + end + + def fetch_custom_field_ids + @project_custom_field_section.custom_fields.pluck(:id) + end + + def enable_custom_fields(custom_field_ids) + existing_mapping_ids = existing_mappings(custom_field_ids) + new_mapping_ids = custom_field_ids - existing_mapping_ids + + create_mappings(new_mapping_ids) if new_mapping_ids.any? + end + + def disable_custom_fields(custom_field_ids) + ProjectCustomFieldProjectMapping + .where(project_id: @project.id, custom_field_id: custom_field_ids) + .delete_all + end + + def existing_mappings(custom_field_ids) + ProjectCustomFieldProjectMapping + .where(project_id: @project.id, custom_field_id: custom_field_ids) + .pluck(:custom_field_id) + end + + def create_mappings(custom_field_ids) + new_mappings = custom_field_ids.map do |id| + { project_id: @project.id, custom_field_id: id } + end + ProjectCustomFieldProjectMapping.insert_all(new_mappings) + end + end +end diff --git a/app/services/project_custom_field_project_mappings/set_attributes_service.rb b/app/services/project_custom_field_project_mappings/set_attributes_service.rb new file mode 100644 index 000000000000..4a6c3007920f --- /dev/null +++ b/app/services/project_custom_field_project_mappings/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_custom_field_project_mappings/toggle_service.rb b/app/services/project_custom_field_project_mappings/toggle_service.rb new file mode 100644 index 000000000000..c9acb5ae4d7b --- /dev/null +++ b/app/services/project_custom_field_project_mappings/toggle_service.rb @@ -0,0 +1,62 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings + class ToggleService < ::BaseServices::Write + def persist(service_result) + if service_result.result.persisted? + # destroy the mapping if it exists and catch any errors which would not be caught by active record + begin + service_result.result.destroy + rescue StandardError => e + service_result.errors = e.message + service_result.success = false + end + else + # create the mapping if it does not exist + unless service_result.result.save + service_result.errors = service_result.result.errors + service_result.success = false + end + end + + service_result + end + + def instance(params) + instance_class.find_or_initialize_by( + project_id: params[:project_id], + custom_field_id: params[:custom_field_id] + ) + end + + def default_contract_class + ProjectCustomFieldProjectMappings::UpdateContract + end + end +end From 8e37370bfd3635502ed27e59deda97f13886fcc0 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 6 Feb 2024 15:28:32 +0700 Subject: [PATCH 058/218] include the acts_as_customizable patch only if project attributes feature is enabled --- app/models/project.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 0691b796701d..7e70bb9ecc82 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,9 +33,13 @@ class Project < ApplicationRecord include Projects::Activity include Projects::Hierarchy include Projects::AncestorsFromRoot - include Projects::ActsAsCustomizablePatches + include ::Scopes::Scoped + if OpenProject::FeatureDecisions.project_attributes_active? + include Projects::ActsAsCustomizablePatches + end + # Maximum length for project identifiers IDENTIFIER_MAX_LENGTH = 100 From 87c79792ba4e1a764ee4cb2fbdff785e620f8f48 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 6 Feb 2024 16:29:44 +0700 Subject: [PATCH 059/218] switched to primer's toggle component on mapping UI --- .../custom_field_row_component.html.erb | 23 ++++++++++++++----- .../custom_field_row_component.rb | 17 +++++++------- config/routes.rb | 2 +- .../settings/mapping_spec.rb | 16 ++++++------- 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index 279fd2284176..7e671f3d0ffd 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -3,14 +3,25 @@ flex_layout(align_items: :center, classes: 'op-project-custom-field', data: { qa_selector: "project-custom-field-#{@project_custom_field.id}" }) do |custom_field_container| - custom_field_container.with_column(mr: 1) do - toggle_button - end - custom_field_container.with_column(data: { qa_selector: "custom-field-type" } ) do - render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do - @project_custom_field.field_format.capitalize + # prototypical implementation of the toggle button in order to review with the team + # TODO: once finally decided, it needs refactoring + custom_field_container.with_column(style: "width: 245px;", flex_layout: true) do |description_container| + description_container.with_column(mr: 2) do + render(Primer::Beta::Truncate.new) do |component| + component.with_item(max_width: 170) do + @project_custom_field.name + end + end + end + description_container.with_column(data: { qa_selector: "custom-field-type" } ) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize + end end end + custom_field_container.with_column do + toggle_button + end end end %> diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 4139e73509a6..551cbec465e9 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -55,21 +55,20 @@ def active_in_project? end def toggle_button - render(Primer::Beta::Button.new( - tag: :a, - href: toggle_project_settings_project_custom_fields_path( + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_project_custom_fields_path( project_custom_field_project_mapping: { project_id: @project.id, custom_field_id: @project_custom_field.id } ), - scheme: :invisible, + csrf_token: form_authenticity_token, data: { 'turbo-method': :put, 'turbo-stream': true, - qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" } - )) do |button| - button.with_leading_visual_icon(icon: (active_in_project? ? 'check-circle' : 'circle')) - @project_custom_field.name - end + qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, + checked: active_in_project?, + size: :small, + status_label_position: :end + )) end end end diff --git a/config/routes.rb b/config/routes.rb index 60da8f8b1934..a615d1313e5a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -191,7 +191,7 @@ resource :types, only: %i[show update] resource :project_custom_fields, only: %i[show] do member do - put :toggle + post :toggle end collection do put :enable_all_of_section diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb index f1bf221ad049..a3ef7368c562 100644 --- a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -106,7 +106,7 @@ visit project_settings_general_path(project) within '#menu-sidebar' do - expect(page).not_to have_css("li[data-name='settings_project_custom_fields']") + expect(page).to have_no_css("li[data-name='settings_project_custom_fields']") end end @@ -128,7 +128,7 @@ visit project_settings_general_path(project) within '#menu-sidebar' do - expect(page).not_to have_css("li[data-name='settings_project_custom_fields']") + expect(page).to have_no_css("li[data-name='settings_project_custom_fields']") end end end @@ -182,7 +182,7 @@ within_custom_field_container(boolean_project_custom_field) do expect_unchecked_state - page.find("[data-qa-selector='toggle-project-custom-field-mapping-#{boolean_project_custom_field.id}']").click + page.find("[data-qa-selector='toggle-project-custom-field-mapping-#{boolean_project_custom_field.id}'] > button").click expect_checked_state # without reloading the page end @@ -275,15 +275,15 @@ within_custom_field_section_container(section_for_input_fields) do expect(page).to have_content('Boolean field') - expect(page).not_to have_content('String field') + expect(page).to have_no_content('String field') end within_custom_field_section_container(section_for_select_fields) do - expect(page).not_to have_content('List field') + expect(page).to have_no_content('List field') end within_custom_field_section_container(section_for_multi_select_fields) do - expect(page).not_to have_content('Multi list field') + expect(page).to have_no_content('Multi list field') end end @@ -346,11 +346,11 @@ def expect_type(type) end def expect_checked_state - expect(page).to have_css('.octicon-check-circle') + expect(page).to have_css('.ToggleSwitch-statusOn') end def expect_unchecked_state - expect(page).to have_css('.octicon-circle') + expect(page).to have_css('.ToggleSwitch-statusOff') end def within_custom_field_section_container(section, &) From f3c474fc119751f124ce06710f8f1bba93e92569 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 7 Feb 2024 10:56:56 +0700 Subject: [PATCH 060/218] adjust toggle switch based on feedback --- .../custom_field_row_component.html.erb | 27 +++++++++++++------ .../custom_field_row_component.rb | 17 ------------ 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index 7e671f3d0ffd..27115cc850fb 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -1,16 +1,14 @@ <%= component_wrapper do - flex_layout(align_items: :center, classes: 'op-project-custom-field', data: { + flex_layout(align_items: :center, justify_content: :space_between, classes: 'op-project-custom-field', data: { qa_selector: "project-custom-field-#{@project_custom_field.id}" }) do |custom_field_container| # prototypical implementation of the toggle button in order to review with the team # TODO: once finally decided, it needs refactoring - custom_field_container.with_column(style: "width: 245px;", flex_layout: true) do |description_container| + custom_field_container.with_column(py: 1, flex_layout: true) do |description_container| description_container.with_column(mr: 2) do - render(Primer::Beta::Truncate.new) do |component| - component.with_item(max_width: 170) do - @project_custom_field.name - end + render(Primer::Beta::Text.new) do + @project_custom_field.name end end description_container.with_column(data: { qa_selector: "custom-field-type" } ) do @@ -19,8 +17,21 @@ end end end - custom_field_container.with_column do - toggle_button + custom_field_container.with_column() do + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_id: @project_custom_field.id + } + ), + csrf_token: form_authenticity_token, + data: { 'turbo-method': :put, 'turbo-stream': true, + qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, + checked: active_in_project?, + size: :small, + status_label_position: :start + )) end end end diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 551cbec465e9..4186ad637aa3 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -53,23 +53,6 @@ def active_in_project? mapping.custom_field_id == @project_custom_field.id end end - - def toggle_button - render(Primer::Alpha::ToggleSwitch.new( - src: toggle_project_settings_project_custom_fields_path( - project_custom_field_project_mapping: { - project_id: @project.id, - custom_field_id: @project_custom_field.id - } - ), - csrf_token: form_authenticity_token, - data: { 'turbo-method': :put, 'turbo-stream': true, - qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, - checked: active_in_project?, - size: :small, - status_label_position: :end - )) - end end end end From 8eb4c1578dc1374c282cbecbf44bd4e6eddacc97 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 7 Feb 2024 11:07:04 +0700 Subject: [PATCH 061/218] cleanup --- .../custom_field_row_component.html.erb | 8 +++++--- .../project_custom_fields_controller.rb | 17 +++-------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index 27115cc850fb..bc485e076a46 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -3,8 +3,7 @@ flex_layout(align_items: :center, justify_content: :space_between, classes: 'op-project-custom-field', data: { qa_selector: "project-custom-field-#{@project_custom_field.id}" }) do |custom_field_container| - # prototypical implementation of the toggle button in order to review with the team - # TODO: once finally decided, it needs refactoring + # py: 1 quick fix: prevents the row from bouncing as the toggle switch currently changes height while toggling custom_field_container.with_column(py: 1, flex_layout: true) do |description_container| description_container.with_column(mr: 2) do render(Primer::Beta::Text.new) do @@ -17,7 +16,10 @@ end end end - custom_field_container.with_column() do + custom_field_container.with_column() do + # buggy currently: + # small variant + status_label_position: :start leads to a small bounce while toggling + # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small render(Primer::Alpha::ToggleSwitch.new( src: toggle_project_settings_project_custom_fields_path( project_custom_field_project_mapping: { diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index d5c64d2349bc..728fd88f106b 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -37,7 +37,6 @@ class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsCont before_action :eager_load_project_custom_field_project_mappings, only: %i[show toggle enable_all_of_section disable_all_of_section] - before_action :set_project_custom_field, only: %i[toggle] before_action :set_project_custom_field_section, only: %i[enable_all_of_section disable_all_of_section] def show; end @@ -47,15 +46,12 @@ def toggle .new(user: current_user) .call(permitted_params.project_custom_field_project_mapping) + # we don't need to rerender a component as the toggle switch shows the correct state base on a successful response if call.success? - eager_load_project_custom_field_project_mappings # reload mappings - - update_custom_field_row_via_turbo_stream + render json: {}, status: :ok else - # TODO: handle error + render json: {}, status: :unprocessable_entity end - - respond_with_turbo_streams end def enable_all_of_section @@ -105,13 +101,6 @@ def eager_load_project_custom_field_project_mappings .to_a end - def set_project_custom_field - # required for component rerenderings - @project_custom_field = ProjectCustomField.find( - permitted_params.project_custom_field_project_mapping[:custom_field_id] - ) - end - def set_project_custom_field_section @project_custom_field_section = ProjectCustomFieldSection.find( permitted_params.project_custom_field_project_mapping[:custom_field_section_id] From 99dbfceda6d558c27baeeed3517472896222b617 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 7 Feb 2024 11:21:42 +0700 Subject: [PATCH 062/218] switch to op_primer header component --- .../header_component.html.erb | 15 ------- .../project_custom_fields/header_component.rb | 44 ------------------- .../project_custom_fields/show.html.erb | 8 +++- 3 files changed, 7 insertions(+), 60 deletions(-) delete mode 100644 app/components/projects/settings/project_custom_fields/header_component.html.erb delete mode 100644 app/components/projects/settings/project_custom_fields/header_component.rb diff --git a/app/components/projects/settings/project_custom_fields/header_component.html.erb b/app/components/projects/settings/project_custom_fields/header_component.html.erb deleted file mode 100644 index d7e5a689eac9..000000000000 --- a/app/components/projects/settings/project_custom_fields/header_component.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<%= - flex_layout do |header_container| - header_container.with_row do - render(Primer::Beta::Heading.new(tag: :h1)) { t('projects.settings.project_custom_fields.header.title') } - end - header_container.with_row(mt: 1, pb: 3, mb: 2, border: :bottom) do - render(Primer::Beta::Text.new(color: :muted)) do - t('projects.settings.project_custom_fields.header.description', - overview_url: project_path(@project), - admin_settings_url: admin_settings_project_custom_fields_path - ).html_safe - end - end - end -%> diff --git a/app/components/projects/settings/project_custom_fields/header_component.rb b/app/components/projects/settings/project_custom_fields/header_component.rb deleted file mode 100644 index c3881b428e8e..000000000000 --- a/app/components/projects/settings/project_custom_fields/header_component.rb +++ /dev/null @@ -1,44 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2023 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 Projects - module Settings - module ProjectCustomFields - class HeaderComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - - def initialize(project:) - super - - @project = project - end - end - end - end -end diff --git a/app/views/projects/settings/project_custom_fields/show.html.erb b/app/views/projects/settings/project_custom_fields/show.html.erb index 9b62dc8d9f5d..0941aa2111f5 100644 --- a/app/views/projects/settings/project_custom_fields/show.html.erb +++ b/app/views/projects/settings/project_custom_fields/show.html.erb @@ -27,7 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%>
- <%= render(Projects::Settings::ProjectCustomFields::HeaderComponent.new(project: @project)) %> + <%= render Primer::OpenProject::PageHeader.new do |header| %> + <%= header.with_title(variant: :default) { t('projects.settings.project_custom_fields.header.title') } %> + <%= header.with_description { t('projects.settings.project_custom_fields.header.description', + overview_url: project_path(@project), + admin_settings_url: admin_settings_project_custom_fields_path + ).html_safe } %> + <% end %> <%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( project: @project, project_custom_field_sections: @project_custom_field_sections, From fdf08efc3c9baecd5ab5556caaf27ddbb0bfc995 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 7 Feb 2024 12:57:18 +0700 Subject: [PATCH 063/218] WIP: enable required custom fields automatically in all projects and disallow disabling them on project level --- .../custom_field_row_component.html.erb | 48 +++++++++++-------- .../base_contract.rb | 9 ++++ app/models/project_custom_field.rb | 18 ++++--- .../bulk_edit_service.rb | 3 +- 4 files changed, 52 insertions(+), 26 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index bc485e076a46..fb574113e048 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -10,30 +10,40 @@ @project_custom_field.name end end - description_container.with_column(data: { qa_selector: "custom-field-type" } ) do + description_container.with_column(mr: 2, data: { qa_selector: "custom-field-type" } ) do render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do @project_custom_field.field_format.capitalize end end end - custom_field_container.with_column() do - # buggy currently: - # small variant + status_label_position: :start leads to a small bounce while toggling - # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small - render(Primer::Alpha::ToggleSwitch.new( - src: toggle_project_settings_project_custom_fields_path( - project_custom_field_project_mapping: { - project_id: @project.id, - custom_field_id: @project_custom_field.id - } - ), - csrf_token: form_authenticity_token, - data: { 'turbo-method': :put, 'turbo-stream': true, - qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, - checked: active_in_project?, - size: :small, - status_label_position: :start - )) + custom_field_container.with_column(align_items: :center, flex_layout: true) do |toggle_container| + if @project_custom_field.required? + toggle_container.with_column(data: { qa_selector: "custom-field-type" } ) do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + t("label_required") + end + end + end + toggle_container.with_column do + # buggy currently: + # small variant + status_label_position: :start leads to a small bounce while toggling + # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_id: @project_custom_field.id + } + ), + csrf_token: form_authenticity_token, + data: { 'turbo-method': :put, 'turbo-stream': true, + qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, + checked: active_in_project?, + enabled: !@project_custom_field.required?, # required fields cannot be disabled + size: :small, + status_label_position: :start + )) + end end end end diff --git a/app/contracts/project_custom_field_project_mappings/base_contract.rb b/app/contracts/project_custom_field_project_mappings/base_contract.rb index aa362cde087b..fa50115f8bd2 100644 --- a/app/contracts/project_custom_field_project_mappings/base_contract.rb +++ b/app/contracts/project_custom_field_project_mappings/base_contract.rb @@ -32,11 +32,20 @@ class BaseContract < ::ModelContract attribute :custom_field_id validate :validate_has_select_project_custom_fields_permission + validate :validate_is_not_required def validate_has_select_project_custom_fields_permission return if user.allowed_in_project?(:select_project_custom_fields, model.project) errors.add :base, :error_unauthorized end + + def validate_is_not_required + # only mappings of custom fields which are not required can be manipulated by the user + # enabling a custom field which is required happens in an after_save hook within the custom field model itself + return if model.project_custom_field.nil? || !model.project_custom_field.required? + + errors.add :custom_field_id, :invalid + end end end diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 489b12f09660..539724a4ad3b 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -33,7 +33,7 @@ class ProjectCustomField < CustomField acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] - # after_create :activate_in_all_projects + after_save :activate_required_field_in_all_projects def type_name :label_project_plural @@ -47,9 +47,15 @@ def self.visible(user = User.current) end end - # TODO: check if custom field is set to be activated in all projects in creation form - # def activate_in_all_projects - # mappings = Project.active.map { |project| { project_id: project.id, custom_field_id: id } } - # ProjectCustomFieldProjectMapping.create!(mappings) - # end + # TODO: write specs for this + def activate_required_field_in_all_projects + return unless required? + + already_activated_in_project_ids = ProjectCustomFieldProjectMapping.where(custom_field_id: id).pluck(:project_id) + + mappings = Project.where.not(id: already_activated_in_project_ids).map do |project| + { project_id: project.id, custom_field_id: id } + end + ProjectCustomFieldProjectMapping.create!(mappings) + end end diff --git a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb b/app/services/project_custom_field_project_mappings/bulk_edit_service.rb index e504b8aa0922..0795b244a7f1 100644 --- a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb +++ b/app/services/project_custom_field_project_mappings/bulk_edit_service.rb @@ -70,7 +70,8 @@ def perform_bulk_edit(service_call, params) end def fetch_custom_field_ids - @project_custom_field_section.custom_fields.pluck(:id) + # only custom fields which are not set to required can be disabled + @project_custom_field_section.custom_fields.where(is_required: false).pluck(:id) end def enable_custom_fields(custom_field_ids) From 4b3fb79171a2cc360c144c29ac20a90fe9f91ea3 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 8 Feb 2024 17:39:26 +0700 Subject: [PATCH 064/218] force all required project custom fields to be enabled in all projects --- app/models/project_custom_field.rb | 1 + .../projects/acts_as_customizable_patches.rb | 31 +++++++++++++++++-- ...d_project_custom_fields_in_all_projects.rb | 17 ++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 539724a4ad3b..accd5426120a 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -48,6 +48,7 @@ def self.visible(user = User.current) end # TODO: write specs for this + # TODO: write migrations for existing required custom fields def activate_required_field_in_all_projects return unless required? diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 6e0fb7a015ce..a37ae5236b1c 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -35,10 +35,35 @@ module Projects::ActsAsCustomizablePatches # not needed for now, but might be relevant if we want to have edit dialogs just for one custom field included do + has_many :project_custom_field_project_mappings, class_name: 'ProjectCustomFieldProjectMapping', foreign_key: :project_id, + dependent: :destroy, inverse_of: :project + + before_save :build_missing_project_custom_field_project_mappings + + def build_missing_project_custom_field_project_mappings + # activate custom fields for this project (via mapping table) if values have been provided for custom_fields but no mapping exists + # current shortcommings: + # - boolean custom fields are always activated as a nil value is never provided (always true/false) + # - custom fields with a default value are always activated as a nil value is never provided (even if set to blank in project creation form) + custom_field_ids = project.custom_values.reject { |cv| cv.value.blank? }.pluck(:custom_field_id) + activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id) + + mappings = (custom_field_ids - activated_custom_field_ids) + .map { |pcf_id| { project_id: id, custom_field_id: pcf_id } } + + project_custom_field_project_mappings.build(mappings) + end + def active_custom_field_ids_of_project - @active_custom_field_ids_of_project ||= ProjectCustomFieldProjectMapping - .where(project_id: project.id) - .pluck(:custom_field_id) + # show all project custom fields in the project creation form + # later on, only those with values will be activated via before_save hook `build_missing_project_custom_field_project_mappings` + # a persisted project will then only show the activated custom fields + # this approach also supports project duplication based on project templates + if new_record? + ProjectCustomField.pluck(:id) + else + project_custom_field_project_mappings.pluck(:custom_field_id) + end end def available_custom_fields diff --git a/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb b/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb new file mode 100644 index 000000000000..9da66c00d584 --- /dev/null +++ b/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb @@ -0,0 +1,17 @@ +class EnableRequiredProjectCustomFieldsInAllProjects < ActiveRecord::Migration[7.1] + def up + required_project_custom_fields = ProjectCustomField.required.find_each.to_a + + Project.includes(:project_custom_field_project_mappings).find_each do |project| + required_project_custom_fields.each do |pcf| + if project.project_custom_field_project_mappings.pluck(:custom_field_id).exclude?(pcf.id) + ProjectCustomFieldProjectMapping.create!(project_id: project.id, custom_field_id: pcf.id) + end + end + end + end + + def down + # reversing this migration is not possible as we don't store the original state + end +end From 26c91673c208319e85cdbbe0081d429e90e27b09 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 8 Feb 2024 17:52:06 +0700 Subject: [PATCH 065/218] adjusted toggle switch position according to feedback --- .../custom_field_row_component.html.erb | 72 +++++++++---------- app/models/project_custom_field.rb | 1 - 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index fb574113e048..de2b1c0e17c6 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -1,49 +1,45 @@ <%= component_wrapper do - flex_layout(align_items: :center, justify_content: :space_between, classes: 'op-project-custom-field', data: { - qa_selector: "project-custom-field-#{@project_custom_field.id}" - }) do |custom_field_container| + flex_layout(align_items: :center, classes: 'op-project-custom-field', data: { + qa_selector: "project-custom-field-#{@project_custom_field.id}" + }) do |custom_field_container| # py: 1 quick fix: prevents the row from bouncing as the toggle switch currently changes height while toggling - custom_field_container.with_column(py: 1, flex_layout: true) do |description_container| - description_container.with_column(mr: 2) do - render(Primer::Beta::Text.new) do - @project_custom_field.name - end + custom_field_container.with_column(py: 1, mr: 2) do + # buggy currently: + # small variant + status_label_position: :start leads to a small bounce while toggling + # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_id: @project_custom_field.id + } + ), + csrf_token: form_authenticity_token, + data: { 'turbo-method': :put, 'turbo-stream': true, + qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, + checked: active_in_project?, + enabled: !@project_custom_field.required?, # required fields cannot be disabled + size: :small, + status_label_position: :start + )) + end + custom_field_container.with_column(pt: 1, mr: 2) do + render(Primer::Beta::Text.new) do + @project_custom_field.name end - description_container.with_column(mr: 2, data: { qa_selector: "custom-field-type" } ) do - render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do - @project_custom_field.field_format.capitalize - end + end + custom_field_container.with_column(pt: 1, mr: 2, data: { qa_selector: "custom-field-type" } ) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize end end - custom_field_container.with_column(align_items: :center, flex_layout: true) do |toggle_container| - if @project_custom_field.required? - toggle_container.with_column(data: { qa_selector: "custom-field-type" } ) do - render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do - t("label_required") - end + if @project_custom_field.required? + custom_field_container.with_column(pt: 1) do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + t("label_required") end end - toggle_container.with_column do - # buggy currently: - # small variant + status_label_position: :start leads to a small bounce while toggling - # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small - render(Primer::Alpha::ToggleSwitch.new( - src: toggle_project_settings_project_custom_fields_path( - project_custom_field_project_mapping: { - project_id: @project.id, - custom_field_id: @project_custom_field.id - } - ), - csrf_token: form_authenticity_token, - data: { 'turbo-method': :put, 'turbo-stream': true, - qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, - checked: active_in_project?, - enabled: !@project_custom_field.required?, # required fields cannot be disabled - size: :small, - status_label_position: :start - )) - end end end end diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index accd5426120a..539724a4ad3b 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -48,7 +48,6 @@ def self.visible(user = User.current) end # TODO: write specs for this - # TODO: write migrations for existing required custom fields def activate_required_field_in_all_projects return unless required? From 0facd6fc0bda9a735ab28f7fe9c6feaec177da6a Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 12 Feb 2024 12:08:12 +0700 Subject: [PATCH 066/218] disable custom fields with default values when user explicitly provided a blank value --- .../projects/acts_as_customizable_patches.rb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index a37ae5236b1c..e4ebb4fa890a 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -39,14 +39,14 @@ module Projects::ActsAsCustomizablePatches dependent: :destroy, inverse_of: :project before_save :build_missing_project_custom_field_project_mappings + after_create :disable_custom_fields_with_empty_values def build_missing_project_custom_field_project_mappings # activate custom fields for this project (via mapping table) if values have been provided for custom_fields but no mapping exists # current shortcommings: # - boolean custom fields are always activated as a nil value is never provided (always true/false) - # - custom fields with a default value are always activated as a nil value is never provided (even if set to blank in project creation form) - custom_field_ids = project.custom_values.reject { |cv| cv.value.blank? }.pluck(:custom_field_id) - activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id) + custom_field_ids = project.custom_values.reject { |cv| cv.value.blank? }.pluck(:custom_field_id).uniq + activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id).uniq mappings = (custom_field_ids - activated_custom_field_ids) .map { |pcf_id| { project_id: id, custom_field_id: pcf_id } } @@ -54,6 +54,19 @@ def build_missing_project_custom_field_project_mappings project_custom_field_project_mappings.build(mappings) end + def disable_custom_fields_with_empty_values + # run only on initial creation! (otherwise we would deactivate custom fields with empty values on every update!) + # + # ideally, `build_missing_project_custom_field_project_mappings` would not activate custom fields with empty values + # but: + # this hook is required as acts_as_customizable build custom values with their default value even if a blank value was provided in the project creation form + # `build_missing_project_custom_field_project_mappings` will then activate the custom field although the user explicitly provided a blank value + # in order to not patch `acts_as_customizable` further, we simply identify these custom values and deactivate the custom field + custom_field_ids = project.custom_values.select { |cv| cv.value.blank? && !cv.required? }.pluck(:custom_field_id) + + project_custom_field_project_mappings.where(custom_field_id: custom_field_ids).destroy_all + end + def active_custom_field_ids_of_project # show all project custom fields in the project creation form # later on, only those with values will be activated via before_save hook `build_missing_project_custom_field_project_mappings` From 2e54aa00eda8fd404959e67901b63923652ea6e7 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 12 Feb 2024 14:22:28 +0700 Subject: [PATCH 067/218] added project creation feature specs around project custom fields and their mapping --- .../projects/acts_as_customizable_patches.rb | 1 + spec/features/projects/create_spec.rb | 103 ++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index e4ebb4fa890a..c630fd8fc33d 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -37,6 +37,7 @@ module Projects::ActsAsCustomizablePatches included do has_many :project_custom_field_project_mappings, class_name: 'ProjectCustomFieldProjectMapping', foreign_key: :project_id, dependent: :destroy, inverse_of: :project + has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' before_save :build_missing_project_custom_field_project_mappings after_create :disable_custom_fields_with_empty_values diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index dea5ad1e81ff..0993bbc731ad 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -111,11 +111,13 @@ context 'with optional and required custom fields' do let!(:optional_custom_field) do create(:custom_field, name: 'Optional Foo', + field_format: 'string', type: ProjectCustomField, is_for_all: true) end let!(:required_custom_field) do create(:custom_field, name: 'Required Foo', + field_format: 'string', type: ProjectCustomField, is_for_all: true, is_required: true) @@ -133,5 +135,106 @@ expect(page).to have_no_text 'Required Foo' end end + + context 'with correct custom field activation' do + let!(:unused_custom_field) do + create(:custom_field, name: 'Unused Foo', + field_format: 'string', + type: ProjectCustomField, + is_for_all: true) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + it 'enables custom fields with provided values for this project' do + fill_in 'Optional Foo', with: 'Optional value' + fill_in 'Unused Foo', with: '' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # unused custom field should not be activated + expect(project.project_custom_field_ids).to contain_exactly( + optional_custom_field.id, required_custom_field.id + ) + end + + context 'with correct handling of default values' do + let!(:custom_field_with_default_value) do + create(:custom_field, name: 'Foo with default value', + field_format: 'string', + default_value: 'Default value', + type: ProjectCustomField, + is_for_all: true) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + it 'enables custom fields with default values if not set to blank explicitly' do + # don't touch the default value + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the default value + expect(project.project_custom_field_ids).to contain_exactly( + custom_field_with_default_value.id, required_custom_field.id + ) + + expect(project.custom_value_for(custom_field_with_default_value).value).to eq('Default value') + end + + it 'does not enable custom fields with default values if set to blank explicitly' do + # native blank input does not work with this input, using support class here + field = FormFields::InputFormField.new(custom_field_with_default_value) + field.set_value("") + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should not be activated + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id + ) + end + + it 'does enable custom fields with default values if overwritten with a new value' do + fill_in 'Foo with default value', with: 'foo' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the overwritten value + expect(project.project_custom_field_ids).to contain_exactly( + custom_field_with_default_value.id, required_custom_field.id + ) + + expect(project.custom_value_for(custom_field_with_default_value).value).to eq('foo') + end + end + end end end From 5101a41ad589240d54ca02b5d3f0c0f3d24700f2 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 13 Feb 2024 15:02:31 +0700 Subject: [PATCH 068/218] added project creation and copy feature specs around project custom fields and their activation --- spec/features/projects/copy_spec.rb | 158 +++++++++++++++++++++++++- spec/features/projects/create_spec.rb | 89 +++++++++++++++ 2 files changed, 241 insertions(+), 6 deletions(-) diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 9ea35aab52bc..54da1af66632 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -35,7 +35,12 @@ parent: parent_project, types: active_types, members: { user => role }, - custom_field_values: { project_custom_field.id => 'some text cf' }).tap do |p| + # custom_fields which are not used below are not activated for this project + custom_field_values: { + project_custom_field.id => 'some text cf', + optional_project_custom_field.id => 'some optional text cf', + optional_project_custom_field_with_default.id => 'foo' + }).tap do |p| p.work_package_custom_fields << wp_custom_field p.types.first.custom_fields << wp_custom_field @@ -56,6 +61,12 @@ let!(:project_custom_field) do create(:text_project_custom_field, is_required: true) end + let!(:optional_project_custom_field) do + create(:text_project_custom_field, is_required: false) + end + let!(:optional_project_custom_field_with_default) do + create(:text_project_custom_field, is_required: false, default_value: 'foo') + end let!(:wp_custom_field) do create(:text_wp_custom_field) end @@ -140,6 +151,137 @@ clear_performed_jobs end + context 'with correct project custom field activations' do + before do + original_settings_page = Pages::Projects::Settings.new(project) + original_settings_page.visit! + + find('.toolbar a', text: 'Copy').click + + expect(page).to have_text "Copy project \"#{project.name}\"" + + fill_in 'Name', with: 'Copied project' + end + + it 'enables the same project custom fields as activated on the source project if untouched' do + expect(project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + click_button 'Save' + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: 'Copied project') + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + end + + it 'disables optional project custom fields if explicitly set to blank' do + # Expand advanced settings + click_on 'Advanced settings' + + editor = Components::WysiwygEditor.new "[data-qa-field-name='customField#{optional_project_custom_field.id}']" + editor.clear + + click_button 'Save' + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: 'Copied project') + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field_with_default.id + ) + end + + # TBD: Is this intended from a conceptial point of view? + # + # If not, I don't know how to change this behavior while keeping the behavior specified in the creation spec where + # optional custom fields are not activated if the value is set to blank in the form (which seems to be desired from + # a concpetional point of view) + it 'does not enable project custom fields (with default values) if set to blank in source project' do + project.update!(custom_field_values: { + optional_project_custom_field.id => '', + optional_project_custom_field_with_default.id => '' + }) + + # the optional custom fields are activated, but set to blank values + expect(project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + original_settings_page = Pages::Projects::Settings.new(project) + original_settings_page.visit! + + find('.toolbar a', text: 'Copy').click + + expect(page).to have_text "Copy project \"#{project.name}\"" + + fill_in 'Name', with: 'Copied project' + + click_button 'Save' + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: 'Copied project') + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id + ) + end + + context 'with project custom fields with default values, which are disabled in source project' do + let!(:optional_boolean_project_custom_field_with_default) do + create(:boolean_project_custom_field, is_required: false, default_value: true) + end + let!(:optional_string_project_custom_field_with_default) do + create(:string_project_custom_field, is_required: false, default_value: 'bar') + end + + # TBD: Is this intended from a conceptial point of view? + # + # If not, I don't know how to change this behavior while keeping the behavior specified in the creation spec where + # optional custom fields with default values are activated if the value is untouched in the form (which seems to be desired from + # a concpetional point of view) + it 'does enable optional project custom fields with default values although not enabled in source project' do + # the optional boolean and string fields are not activated in the source project + expect(project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + click_button 'Save' + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: 'Copied project') + + # the optional boolean and string fields are activated in the copied project with their default values + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id, + optional_boolean_project_custom_field_with_default.id, + optional_string_project_custom_field_with_default.id + ) + + expect(copied_project.custom_value_for(optional_boolean_project_custom_field_with_default).typed_value).to be_truthy + expect(copied_project.custom_value_for(optional_string_project_custom_field_with_default).typed_value).to eq('bar') + end + end + end + it 'copies projects and the associated objects' do original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! @@ -159,11 +301,7 @@ click_button 'Save' - expect(page).to have_text 'The job has been queued and will be processed shortly.' - - # ensure all jobs are run especially emails which might be sent later on - while perform_enqueued_jobs > 0 - end + wait_for_copy_to_finish copied_project = Project.find_by(name: 'Copied project') @@ -300,4 +438,12 @@ wp_table.expect_work_package_order *order end end + + def wait_for_copy_to_finish + expect(page).to have_text 'The job has been queued and will be processed shortly.' + + # ensure all jobs are run especially emails which might be sent later on + while perform_enqueued_jobs > 0 + end + end end diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index 0993bbc731ad..9ecb723e610d 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -136,6 +136,19 @@ end end + context 'with correct validations' do + before do + visit new_project_path + end + + it 'requires the required custom field' do + click_on 'Save' + + expect(page).to have_content "Required Foo can't be blank" + expect(page).to have_no_content "Optional Foo can't be blank" + end + end + context 'with correct custom field activation' do let!(:unused_custom_field) do create(:custom_field, name: 'Unused Foo', @@ -235,6 +248,82 @@ expect(project.custom_value_for(custom_field_with_default_value).value).to eq('foo') end end + + context 'with correct handling of optional boolean values' do + let!(:custom_boolean_field_default_true) do + create(:custom_field, name: 'Boolean with default true', + field_format: 'bool', + default_value: true, + type: ProjectCustomField, + is_for_all: true) + end + + let!(:custom_boolean_field_with_no_default) do + create(:custom_field, name: 'Boolean with no default', + field_format: 'bool', + type: ProjectCustomField, + is_for_all: true) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + it 'only enables boolean custom fields with default values if untouched' do + # do not touch any of the boolean fields + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the overwritten value + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, custom_boolean_field_default_true.id + ) + + expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_truthy + end + + it 'enables boolean custom fields without default values if set to true explicitly' do + check 'Boolean with no default' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the overwritten value + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, custom_boolean_field_default_true.id, custom_boolean_field_with_no_default.id + ) + + expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_truthy + expect(project.custom_value_for(custom_boolean_field_with_no_default).typed_value).to be_truthy + end + + it 'enables boolean custom fields with default values if set to false explicitly' do + uncheck 'Boolean with default true' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the overwritten value + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, custom_boolean_field_default_true.id + ) + + expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_falsy + end + end end end end From 95242af34a032cc18bd2e0c9a2abc2320a97d5cc Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 13 Feb 2024 15:12:11 +0700 Subject: [PATCH 069/218] fixed default value display approach --- .../show_component.html.erb | 8 ++--- .../overview_page/sidebar_spec.rb | 34 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb index 09204591f3f7..a7ae2774671c 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb @@ -9,11 +9,11 @@ custom_field_value_container.with_row do if not_set? - if @project_custom_field.default_value.present? - render(Primer::Beta::Text.new()) { render_formatted_default_value } - else + # if @project_custom_field.default_value.present? + # render(Primer::Beta::Text.new()) { render_formatted_default_value } + # else render(Primer::Beta::Text.new()) { t('label_not_set_yet') } - end + # end else render(Primer::Beta::Text.new()) { render_formatted_value } end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index eed3c401f36e..97aba36d81e9 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -225,7 +225,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'does not show the default value for the project custom field if no value given' do boolean_project_custom_field.update!(default_value: true) overview_page.visit_page @@ -233,7 +233,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text 'Boolean field' - expect(page).to have_text 'Yes' + expect(page).to have_text 'Not set yet' end end @@ -244,7 +244,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text 'Boolean field' - expect(page).to have_text 'No' + expect(page).to have_text 'Not set yet' end end end @@ -298,7 +298,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'does not show the default value for the project custom field if no value given' do string_project_custom_field.update!(default_value: 'Bar') overview_page.visit_page @@ -306,7 +306,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text 'String field' - expect(page).to have_text 'Bar' + expect(page).to have_text 'Not set yet' end end end @@ -360,7 +360,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'does not show the default value for the project custom field if no value given' do integer_project_custom_field.update!(default_value: 456) overview_page.visit_page @@ -368,7 +368,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text 'Integer field' - expect(page).to have_text '456' + expect(page).to have_text 'Not set yet' end end end @@ -422,7 +422,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'does not show the default value for the project custom field if no value given' do date_project_custom_field.update!(default_value: Date.new(2024, 2, 2)) overview_page.visit_page @@ -430,7 +430,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text 'Date field' - expect(page).to have_text '02/02/2024' + expect(page).to have_text 'Not set yet' end end end @@ -484,7 +484,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'dies not show the default value for the project custom field if no value given' do float_project_custom_field.update!(default_value: 456.789) overview_page.visit_page @@ -492,7 +492,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text 'Float field' - expect(page).to have_text '456.789' + expect(page).to have_text 'Not set yet' end end end @@ -546,7 +546,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'does not show the default value for the project custom field if no value given' do text_project_custom_field.update!(default_value: 'Dolor sit amet') overview_page.visit_page @@ -554,7 +554,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text 'Text field' - expect(page).to have_text 'Dolor sit amet' + expect(page).to have_text 'Not set yet' end end end @@ -608,7 +608,7 @@ end end - it 'shows the default value for the project custom field if no value given' do + it 'does not show the default value for the project custom field if no value given' do list_project_custom_field.custom_options.first.update!(default_value: true) overview_page.visit_page @@ -616,7 +616,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text 'List field' - expect(page).to have_text 'Option 1' + expect(page).to have_text 'Not set yet' end end end @@ -759,7 +759,7 @@ end end - it 'shows the default value(s) for the project custom field if no value given' do + it 'does not show the default value(s) for the project custom field if no value given' do multi_list_project_custom_field.custom_options.first.update!(default_value: true) multi_list_project_custom_field.custom_options.second.update!(default_value: true) @@ -768,7 +768,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Option 1, Option 2' + expect(page).to have_text 'Not set yet' end end end From 8475f2201ed8c2644a59d65fba865df7029b3f9e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 14 Feb 2024 11:55:16 +0700 Subject: [PATCH 070/218] added model specs for project custom field model hooks --- app/models/project_custom_field.rb | 1 - spec/models/project_custom_field_spec.rb | 104 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 spec/models/project_custom_field_spec.rb diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 539724a4ad3b..91fd0bf2c6a2 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -47,7 +47,6 @@ def self.visible(user = User.current) end end - # TODO: write specs for this def activate_required_field_in_all_projects return unless required? diff --git a/spec/models/project_custom_field_spec.rb b/spec/models/project_custom_field_spec.rb new file mode 100644 index 000000000000..376bfd811bad --- /dev/null +++ b/spec/models/project_custom_field_spec.rb @@ -0,0 +1,104 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomField do + describe 'activation in projects' do + context 'when creating a new required project custom field' do + let!(:project) { create(:project) } + let!(:another_project) { create(:project) } + + it 'activates the required project custom fields in all projects' do + project_custom_field = create(:project_custom_field, is_required: true) + + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + end + end + + context 'when setting an existing project custom field to required' do + let!(:project_custom_field) { create(:string_project_custom_field) } # optional now + let!(:project) do + create(:project, custom_field_values: { "#{project_custom_field.id}": "foo" }) + end + let!(:another_project) { create(:project) } # not using the custom field + + it 'activates the required project custom fields in all projects where it is not already activated' do + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).not_to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + + project_custom_field.update!(is_required: true) # required now + + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + end + + it 'does not disabled project custom fields when set to optional' do + project_custom_field.update!(is_required: true) # required now + project_custom_field.update!(is_required: false) # optional again + + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + end + + it 'does not create duplicate mappings' do + project_custom_field.update!(is_required: true) # required now + + # mapping existed before, should not be duplicated + expect(ProjectCustomFieldProjectMapping.where(project_id: project.id, + custom_field_id: project_custom_field.id).count).to eq(1) + end + end + + context 'when deleting a project custom field' do + let!(:project_custom_field) { create(:string_project_custom_field) } + let!(:project) do + create(:project, custom_field_values: { "#{project_custom_field.id}": "foo" }) + end + + it 'deletes the project custom field mappings' do + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + + project_custom_field.destroy + + expect(ProjectCustomFieldProjectMapping).not_to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + end + end + end +end From c06fb416e33fc7b55b90feeb89e30477205165d0 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 15 Feb 2024 14:48:50 +0700 Subject: [PATCH 071/218] preview long text values truncated and expand via dialog --- .../show_component.html.erb | 8 +--- .../project_custom_fields/show_component.rb | 46 +++++++++++++++---- .../overview_page/sidebar_spec.rb | 38 ++++++++++++--- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb index a7ae2774671c..f60e84d7baa4 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb @@ -9,13 +9,9 @@ custom_field_value_container.with_row do if not_set? - # if @project_custom_field.default_value.present? - # render(Primer::Beta::Text.new()) { render_formatted_default_value } - # else - render(Primer::Beta::Text.new()) { t('label_not_set_yet') } - # end + render(Primer::Beta::Text.new()) { t('label_not_set_yet') } else - render(Primer::Beta::Text.new()) { render_formatted_value } + render_value end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb index f3e4e0babfe1..ac6ea253d1c2 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb @@ -43,19 +43,45 @@ def initialize(project_custom_field:, project_custom_field_values:) private - def render_formatted_value - @project_custom_field_values&.map do |cf_value| - format_value(cf_value.value, @project_custom_field) - end&.join(", ")&.html_safe + def render_value + if @project_custom_field.field_format == "text" + render_rich_text + else + render(Primer::Beta::Text.new) do + @project_custom_field_values&.map do |cf_value| + format_value(cf_value.value, @project_custom_field) + end&.join(", ")&.html_safe + end + end end - def render_formatted_default_value - if @project_custom_field.default_value.is_a?(Array) - @project_custom_field.default_value.map do |default_value| - format_value(default_value, @project_custom_field) - end.join(", ").html_safe + def render_rich_text + truncation_length = 100 + + if @project_custom_field_values.first&.value&.length.to_i > truncation_length + render_truncated_preview_and_dialog_for_rich_text_value(truncation_length) else - format_value(@project_custom_field.default_value, @project_custom_field) + render(Primer::Beta::Text.new) do + format_value(@project_custom_field_values.first&.value, @project_custom_field) + end + end + end + + def render_truncated_preview_and_dialog_for_rich_text_value(truncation_length) + flex_layout do |rich_text_preview_container| + rich_text_preview_container.with_row do + render(Primer::Beta::Text.new) do + format_value(@project_custom_field_values.first&.value&.truncate(truncation_length), @project_custom_field) + end + end + rich_text_preview_container.with_row do + render(Primer::Alpha::Dialog.new(size: :medium_portrait, title: @project_custom_field.name)) do |d| + d.with_show_button(scheme: :link) { "Expand" } + d.with_body(style: "max-height: 500px;") do + format_value(@project_custom_field_values.first&.value, @project_custom_field) + end + end + end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 97aba36d81e9..0682eb3412b1 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -501,13 +501,39 @@ describe 'with text CF' do describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + context 'with a value that is shorter than 100 characters' do + it 'shows the correct value for the project custom field if given without truncation and dialog button' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text "Lorem\nipsum" + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text "Lorem\nipsum" + end + end + end + end + + context 'with a value that is longer than 100 characters' do + before do + text_project_custom_field.custom_values.where(customized: project).first.update!(value: 'a' * 101) + end + + it 'shows the correct value for the project custom field if given with truncation and dialog button' do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text ("#{'a' * 97}...") + expect(page).to have_text 'Expand' + + click_on 'Expand' + + within 'modal-dialog' do + expect(page).to have_text 'a' * 101 + end + end end end end From e00030b52e3e2db593c6f1e99e71c29b9fac12fe Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 15 Feb 2024 17:29:04 +0700 Subject: [PATCH 072/218] show users with avatar component in sidebar --- .../sections/edit_dialog_component.html.erb | 1 + .../project_custom_fields/show_component.rb | 47 +++++++++++++++---- .../overview_page/sidebar_spec.rb | 5 +- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index 42078c509cc5..d71ff4e93ba5 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -8,6 +8,7 @@ url: update_project_custom_values_path(project_id: @project.id, section_id: @project_custom_field_section.id), ) do |f| component_collection do |collection| + # TODO: remove inline style collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, style: "max-height: 500px;")) do render(Projects::CustomFields::Form.new(f, project: @project, custom_field_section: @project_custom_field_section)) end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb index ac6ea253d1c2..cdbf643354e0 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb @@ -43,14 +43,21 @@ def initialize(project_custom_field:, project_custom_field_values:) private + def not_set? + @project_custom_field_values.empty? || @project_custom_field_values.all? { |cf_value| cf_value.value.blank? } + end + def render_value - if @project_custom_field.field_format == "text" + case @project_custom_field.field_format + when "text" render_rich_text + when "user" + render_user else render(Primer::Beta::Text.new) do @project_custom_field_values&.map do |cf_value| format_value(cf_value.value, @project_custom_field) - end&.join(", ")&.html_safe + end&.join(", ") end end end @@ -71,22 +78,44 @@ def render_truncated_preview_and_dialog_for_rich_text_value(truncation_length) flex_layout do |rich_text_preview_container| rich_text_preview_container.with_row do render(Primer::Beta::Text.new) do - format_value(@project_custom_field_values.first&.value&.truncate(truncation_length), @project_custom_field) + format_value( + @project_custom_field_values.first&.value&.truncate(truncation_length), + @project_custom_field + ) end end rich_text_preview_container.with_row do - render(Primer::Alpha::Dialog.new(size: :medium_portrait, title: @project_custom_field.name)) do |d| - d.with_show_button(scheme: :link) { "Expand" } - d.with_body(style: "max-height: 500px;") do - format_value(@project_custom_field_values.first&.value, @project_custom_field) + render_dialog + end + end + end + + def render_dialog + render(Primer::Alpha::Dialog.new(size: :medium_portrait, title: @project_custom_field.name)) do |dialog| + dialog.with_show_button(scheme: :link) { "Expand" } + # TODO: remove inline style + dialog.with_body(style: "max-height: 500px;") do + format_value(@project_custom_field_values.first&.value, @project_custom_field) + end + end + end + + def render_user + if @project_custom_field.multi_value? + flex_layout do |avatar_container| + @project_custom_field_values&.each do |cf_value| + avatar_container.with_row do + render_avatar(cf_value.typed_value) end end end + else + render_avatar(@project_custom_field_values&.first&.typed_value) end end - def not_set? - @project_custom_field_values.empty? || @project_custom_field_values.all? { |cf_value| cf_value.value.blank? } + def render_avatar(user) + render(Users::AvatarComponent.new(user:, size: :mini)) end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 0682eb3412b1..bfb559075ab9 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -706,6 +706,7 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text 'User field' + expect(page).to have_css('opce-principal') expect(page).to have_text 'Member 1 In Project' end end @@ -841,7 +842,9 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do expect(page).to have_text 'Multi user field' - expect(page).to have_text 'Member 1 In Project, Member 2 In Project' + expect(page).to have_css 'opce-principal', count: 2 + expect(page).to have_text 'Member 1 In Project' + expect(page).to have_text 'Member 2 In Project' end end end From 96a0c13c96db514fe656d4228b5e04ea140e9c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 15 Feb 2024 13:24:26 +0100 Subject: [PATCH 073/218] Set appendTo=body to fix position --- .../custom_fields/inputs/base/autocomplete/user_query_utils.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index c487460a200f..3883686df37d 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -35,7 +35,8 @@ def user_autocomplete_options filters:, searchKey: search_key, inputValue: input_value, - focusDirectly: false + focusDirectly: false, + appendTo: 'body' } end From 69bd7aada3c4189e274247fc6d7514f05d592a2a Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 16 Feb 2024 15:43:51 +0700 Subject: [PATCH 074/218] quick fixing multi user submission --- .../overviews/overviews_controller.rb | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 03f2327f92f4..01f7b4809278 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -32,13 +32,15 @@ def project_custom_field_section_dialog def update_project_custom_values section = find_project_custom_field_section + processed_permitted_params = pre_process(permitted_params.project) + service_call = ::Projects::UpdateService .new( user: current_user, model: @project ) .call( - permitted_params.project.merge( + processed_permitted_params.merge( limit_custom_fields_validation_to_section_id: section.id ) ) @@ -65,6 +67,27 @@ def check_project_attributes_feature_enabled render_404 unless OpenProject::FeatureDecisions.project_attributes_active? end + def pre_process(project_params) + # TODO: find better solution for this: + # quick fixing wrong submit format of multi user custom fields, should be fixed on the frontend side + # multi user autocompleter submits a comma separated list of user ids + # like `project[custom_field_values][40]: 5,4` + # instead of `project[custom_field_values][40][]: 5` and `project[custom_field_values][40][]: 4` + # this leads to parsing errors within the project.custom_field_values= method + # thus we preprocess the params to split the comma separated list into an array here + eager_loaded_custom_fields = @project.project_custom_fields.to_a + + project_params[:custom_field_values].each do |custom_field_id, custom_field_value| + custom_field = eager_loaded_custom_fields.find { |pcf| pcf.id == custom_field_id.to_i } + + if custom_field&.field_format == "user" && custom_field.multi_value? + project_params[:custom_field_values][custom_field_id] = custom_field_value&.split(",") + end + end + + project_params + end + def find_project_custom_field_section ProjectCustomFieldSection.find(params[:section_id]) end From 809880727ec777111d9981308218a30e7ae807b7 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 16 Feb 2024 15:44:24 +0700 Subject: [PATCH 075/218] added dialog update specs and split spec file for better oversight --- .../overview_page/dialog/inputs_spec.rb | 617 ++++++++++++ .../overview_page/dialog/permission_spec.rb | 67 ++ .../overview_page/dialog/render_spec.rb | 145 +++ .../overview_page/dialog/update_spec.rb | 642 ++++++++++++ .../overview_page/dialog/validation_spec.rb | 208 ++++ .../overview_page/dialog_spec.rb | 913 ------------------ .../primerized/autocomplete_field.rb | 2 + .../form_fields/primerized/input_field.rb | 2 +- 8 files changed, 1682 insertions(+), 914 deletions(-) create mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb create mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb delete mode 100644 spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb new file mode 100644 index 000000000000..da39c3bc241a --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb @@ -0,0 +1,617 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + describe 'with correct initialization and input behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field checkbox' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + if expected_initial_value + expect(page).to have_checked_field(custom_field.name) + else + expect(page).to have_no_checked_field(custom_field.name) + end + end + end + + it 'is unchecked if no value and no default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) + end + end + + it 'shows default value if no value is given' do + custom_field.custom_values.destroy_all + + custom_field.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_checked_field(custom_field.name) + end + + custom_field.update!(default_value: false) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) + end + end + end + + shared_examples 'a custom field input' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_initial_value) + end + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_blank_value) + end + end + + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: default_value) + end + end + end + + shared_examples 'a rich text custom field input' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_initial_value) + end + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_blank_value) + end + end + + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(default_value) + end + end + end + + describe 'with boolean CF' do + let(:custom_field) { boolean_project_custom_field } + let(:default_value) { false } + let(:expected_blank_value) { false } + let(:expected_initial_value) { true } + + it_behaves_like 'a custom field checkbox' + end + + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:default_value) { 'Default value' } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 'Foo' } + + it_behaves_like 'a custom field input' + end + + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:default_value) { 789 } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 123 } + + it_behaves_like 'a custom field input' + end + + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:default_value) { 789.123 } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 123.456 } + + it_behaves_like 'a custom field input' + end + + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:default_value) { Date.new(2026, 1, 1) } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { Date.new(2024, 1, 1) } + + it_behaves_like 'a custom field input' + end + + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:default_value) { 'Default value' } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? + + it_behaves_like 'a rich text custom field input' + end + end + + describe 'with single select fields' do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a autocomplete single select field' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(expected_initial_value) + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.expect_blank + end + + it 'filters the list based on the input' do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end + + it 'enables the user to select a single value from a list' do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + field.select_option(second_option) + + field.expect_selected(second_option) + + field.search(third_option) + field.select_option(third_option) + + field.expect_selected(third_option) + field.expect_not_selected(second_option) + end + + it 'clears the input if clicked on the clear button' do + overview_page.open_edit_dialog_for_section(section) + + field.clear + + field.expect_blank + end + end + + describe 'with single select list CF' do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { custom_field.custom_options.first.value } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } + + it_behaves_like 'a autocomplete single select field' + + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + + custom_field.custom_options.first.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(custom_field.custom_options.first.value) + end + end + + describe 'with single version select list CF' do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { first_version.name } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } + + it_behaves_like 'a autocomplete single select field' + + describe 'with correct version scoping' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end + + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Version 1') + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end + end + end + + describe 'with single user select list CF' do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { member_in_project.name } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } + + it_behaves_like 'a autocomplete single select field' + + describe 'with correct user scoping' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + + it 'shows only users that are members of the project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Member 1') + + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) + end + end + + describe 'with support for user groups' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end + + it 'shows only groups that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Group 1') + + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end + end + + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder User', + member_with_roles: { project => reader_role }) + end + + it 'shows the placeholder user' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Placeholder User') + + field.expect_option(placeholder_user.name) + end + end + end + end + + describe 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a autocomplete multi select field' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(*expected_initial_value) + end + + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.expect_blank + end + + it 'filters the list based on the input' do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end + + it 'allows to select multiple values' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.expect_selected(second_option) + field.expect_selected(third_option) + end + + it 'allows to remove selected values' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.deselect_option(third_option) + + field.expect_selected(second_option) + field.expect_not_selected(third_option) + end + + it 'allows to remove all selected values at once' do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.clear + + field.expect_not_selected(second_option) + field.expect_not_selected(third_option) + end + end + + describe 'with multi select list CF' do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } + + it_behaves_like 'a autocomplete multi select field' + + it 'shows the default value if no value is given' do + multi_list_project_custom_field.custom_values.destroy_all + + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(multi_list_project_custom_field.custom_options.first.value) + field.expect_selected(multi_list_project_custom_field.custom_options.second.value) + end + end + + describe 'with multi version select list CF' do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [first_version.name, second_version.name] } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } + + it_behaves_like 'a autocomplete multi select field' + + describe 'with correct version scoping' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end + + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Version 1') + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end + end + end + + describe 'with multi user select list CF' do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } + + it_behaves_like 'a autocomplete multi select field' + + describe 'with correct user scoping' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + + it 'shows only users that are members of the project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Member 1') + + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) + end + end + + describe 'with support for user groups' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: 'Group 2 in project', + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end + + it 'shows only groups that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Group 1') + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end + + it 'enables to select multiple user groups' do + overview_page.open_edit_dialog_for_section(section) + + field.select_option('Group 1 in project') + field.select_option('Group 2 in project') + + field.expect_selected('Group 1 in project') + field.expect_selected('Group 2 in project') + end + end + + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: 'Another placeholder User', + member_with_roles: { project => reader_role }) + end + let!(:placeholder_user_in_other_project) do + create(:placeholder_user, name: 'Placeholder user in other project', + member_with_roles: { other_project => reader_role }) + end + + it 'shows only placeholder users from this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Placeholder User') + + field.expect_option(placeholder_user.name) + field.expect_option(another_placeholder_user.name) + field.expect_no_option(placeholder_user_in_other_project.name) + end + + it 'enables to select multiple placeholder users' do + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) + + field.expect_selected(placeholder_user.name) + field.expect_selected(another_placeholder_user.name) + end + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb new file mode 100644 index 000000000000..2e6b353ff5b7 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb @@ -0,0 +1,67 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + describe 'with insufficient permissions' do + # turboframe sidebar request is covered by a controller spec checking for 403 + # async dialog content request is be covered by a controller spec checking for 403 + # via spec/permissions/manage_project_custom_values_spec.rb + before do + login_as member_without_project_edit_permissions + overview_page.visit_page + end + + it 'does not show the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-edit-button']") + end + end + end + + describe 'with sufficient permissions' do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + it 'shows the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_css("[data-qa-selector='project-custom-field-section-edit-button']", count: 3) + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb new file mode 100644 index 000000000000..0afdd15017ca --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb @@ -0,0 +1,145 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } + + it 'opens a dialog showing inputs for project custom fields of a specific section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.expect_open + end + + it 'renders the dialog body asynchronically' do + expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible) + end + + it 'can be closed via close icon or cancel button' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.close_via_icon + + dialog.expect_closed + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.close_via_button + + dialog.expect_closed + end + + it 'shows only the project custom fields of the specific section within the dialog' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if input_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_select_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_multi_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if multi_select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + end + + it 'shows the inputs in the correct order defined by the position of project custom field in a section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers + + expect(containers[0].text).to include('Boolean field') + expect(containers[1].text).to include('String field') + expect(containers[2].text).to include('Integer field') + expect(containers[3].text).to include('Float field') + expect(containers[4].text).to include('Date field') + expect(containers[5].text).to include('Text field') + end + + boolean_project_custom_field.move_to_bottom + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers + + expect(containers[0].text).to include('String field') + expect(containers[1].text).to include('Integer field') + expect(containers[2].text).to include('Float field') + expect(containers[3].text).to include('Date field') + expect(containers[4].text).to include('Text field') + expect(containers[5].text).to include('Boolean field') + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb new file mode 100644 index 000000000000..2a1e10cf08bc --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -0,0 +1,642 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + describe 'with correct updating behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field checkbox' do + it 'sets the value to true if checked' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Not set yet" + end + + overview_page.open_edit_dialog_for_section(section) + + field.check + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + end + + it 'sets the value to false if unchecked' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + + overview_page.open_edit_dialog_for_section(section) + + field.uncheck + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "No" + end + end + + it 'does not change the value if untouched' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + end + end + + shared_examples 'a custom field input' do + it 'saves the value properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Not set yet" + end + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: update_value) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_updated_value + end + end + + it 'does not change the value if untouched' do + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + end + + it 'removes the value properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '') + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Not set yet" + end + end + end + + shared_examples 'a rich text custom field input' do + it 'saves the value properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text(expected_updated_value) + end + + overview_page.open_edit_dialog_for_section(section) + + field.set_value(update_value) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text(expected_updated_value) + end + end + + it 'does not change the value if untouched' do + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + end + + it 'removes the value properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text(expected_initial_value) + end + + overview_page.open_edit_dialog_for_section(section) + + field.set_value('') + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text(expected_initial_value) + end + end + end + + describe 'with boolean CF' do + let(:custom_field) { boolean_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { true } + + it_behaves_like 'a custom field checkbox' + end + + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 'Foo' } + let(:update_value) { 'Bar' } + let(:expected_updated_value) { update_value } + + it_behaves_like 'a custom field input' + end + + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 123 } + let(:update_value) { 456 } + let(:expected_updated_value) { update_value } + + it_behaves_like 'a custom field input' + end + + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 123.456 } + let(:update_value) { 456.789 } + let(:expected_updated_value) { update_value } + + it_behaves_like 'a custom field input' + end + + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { '01/01/2024' } + let(:update_value) { Date.new(2024, 1, 2) } + let(:expected_updated_value) { '01/02/2024' } + + it_behaves_like 'a custom field input' + end + + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? + let(:update_value) { "Dolor\n\nsit" } + let(:expected_updated_value) { "Dolor\nsit" } + + it_behaves_like 'a rich text custom field input' + end + end + + describe 'with select fields' do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a select field' do + it 'saves the value properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + end + + it 'does not change the value if untouched' do + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + end + + it 'removes the value properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.clear + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end + end + end + + describe 'with list CF' do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { custom_field.custom_options.first.value } + + it_behaves_like 'a select field' + end + + describe 'with version select CF' do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { first_version.name } + + it_behaves_like 'a select field' + end + + describe 'with user select CF' do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { member_in_project.name } + + it_behaves_like 'a select field' + + describe 'with support for user groups' do + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + + it 'saves selected user group properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(group.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text group.name + end + end + end + + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end + + it 'saves selected placeholer user properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text placeholder_user.name + end + end + end + end + end + + describe 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a autocomplete multi select field' do + it 'saves single selected values properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + end + + it 'saves multi selected values properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + field.select_option(second_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + end + + it 'removes deselected values properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.deselect_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_text second_option + end + end + + it 'does not remove values when not touching the init values' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the values + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + end + + it 'removes all values when clearing the input' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.clear + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end + end + + it 'adds values properly to init values' do + custom_field.custom_values.last.destroy + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_no_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + end + end + + describe 'with multi select list CF' do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + + it_behaves_like 'a autocomplete multi select field' + end + + describe 'with multi version select list CF' do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + + it_behaves_like 'a autocomplete multi select field' + end + + describe 'with multi user select list CF' do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + + it_behaves_like 'a autocomplete multi select field' + + describe 'with support for user groups' do + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: 'Group 2 in project', + member_with_roles: { project => reader_role }) + end + + it 'saves selected user groups properly' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(group.name) + field.select_option(another_group.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text group.name + expect(page).to have_text another_group.name + end + end + end + + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: 'Another placeholder User', + member_with_roles: { project => reader_role }) + end + + it 'shows only placeholder users from this project' do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text placeholder_user.name + expect(page).to have_text another_placeholder_user.name + end + end + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb new file mode 100644 index 000000000000..460897129360 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -0,0 +1,208 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + describe 'with correct validation behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field input' do + it 'shows an error if the value is invalid' do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.blank')) + end + end + + # boolean CFs can not be validated + + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 3) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'Foooo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 3, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'Fo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) + end + + it 'shows an error if the value does not match the regex' do + custom_field.update!(regexp: '^[A-Z]+$') + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: 'foo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.invalid')) + end + end + + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 2) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '111') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 2)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 2, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '1') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 2)) + end + end + + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 4) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '1111.1') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 4)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 4, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: '1.1') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 4)) + end + end + + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like 'a custom field input' + end + + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + + it_behaves_like 'a custom field input' + + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 3) + + overview_page.open_edit_dialog_for_section(section) + + field.set_value('Foooo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) + end + + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 3, max_length: 5) + + overview_page.open_edit_dialog_for_section(section) + + field.set_value('Fo') + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb deleted file mode 100644 index 2e48e099b693..000000000000 --- a/spec/features/projects/project_custom_fields/overview_page/dialog_spec.rb +++ /dev/null @@ -1,913 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2023 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' -require_relative 'shared_context' - -RSpec.describe 'Edit project custom fields on project overview page', :js do - include_context 'with seeded projects, members and project custom fields' - - let(:overview_page) { Pages::Projects::Show.new(project) } - - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - describe 'with insufficient permissions' do - # TODO: turboframe sidebar request needs to be covered by a controller spec checking for 403 - # TODO: async dialog content request needs to be covered by a controller spec checking for 403 - before do - login_as member_without_project_edit_permissions - overview_page.visit_page - end - - it 'does not show the edit buttons' do - overview_page.within_async_loaded_sidebar do - expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-edit-button']") - end - end - end - - describe 'with sufficient permissions' do - before do - login_as member_with_project_edit_permissions - overview_page.visit_page - end - - it 'shows the edit buttons' do - overview_page.within_async_loaded_sidebar do - expect(page).to have_css("[data-qa-selector='project-custom-field-section-edit-button']", count: 3) - end - end - - describe 'enables editing of project custom field values via dialog' do - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } - - it 'opens a dialog showing inputs for project custom fields of a specific section' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - dialog.expect_open - end - - it 'renders the dialog body asynchronically' do - expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible) - end - - it 'can be closed via close icon or cancel button' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - dialog.close_via_icon - - dialog.expect_closed - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - dialog.close_via_button - - dialog.expect_closed - end - - it 'shows only the project custom fields of the specific section within the dialog' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - dialog.within_async_content(close_after_yield: true) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if input_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end - end - end - - dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) - - overview_page.open_edit_dialog_for_section(section_for_select_fields) - - dialog.within_async_content(close_after_yield: true) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if select_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end - end - end - - dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_multi_select_fields) - - overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) - - dialog.within_async_content(close_after_yield: true) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if multi_select_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end - end - end - end - - it 'shows the inputs in the correct order defined by the position of project custom field in a section' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - dialog.within_async_content(close_after_yield: true) do - containers = dialog.input_containers - - expect(containers[0].text).to include('Boolean field') - expect(containers[1].text).to include('String field') - expect(containers[2].text).to include('Integer field') - expect(containers[3].text).to include('Float field') - expect(containers[4].text).to include('Date field') - expect(containers[5].text).to include('Text field') - end - - boolean_project_custom_field.move_to_bottom - - overview_page.open_edit_dialog_for_section(section_for_input_fields) - - dialog.within_async_content(close_after_yield: true) do - containers = dialog.input_containers - - expect(containers[0].text).to include('String field') - expect(containers[1].text).to include('Integer field') - expect(containers[2].text).to include('Float field') - expect(containers[3].text).to include('Date field') - expect(containers[4].text).to include('Text field') - expect(containers[5].text).to include('Boolean field') - end - end - - describe 'with correct initialization and input behaviour' do - describe 'with input fields' do - let(:section) { section_for_input_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - - shared_examples 'a custom field checkbox' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - if expected_initial_value - expect(page).to have_checked_field(custom_field.name) - else - expect(page).to have_no_checked_field(custom_field.name) - end - end - end - - it 'is unchecked if no value and no default value is given' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_no_checked_field(custom_field.name) - end - end - - it 'shows default value if no value is given' do - custom_field.custom_values.destroy_all - - custom_field.update!(default_value: true) - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_checked_field(custom_field.name) - end - - custom_field.update!(default_value: false) - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_no_checked_field(custom_field.name) - end - end - end - - shared_examples 'a custom field input' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_field(custom_field.name, with: expected_initial_value) - end - end - - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_field(custom_field.name, with: expected_blank_value) - end - end - - it 'shows the default value if no value is given' do - custom_field.custom_values.destroy_all - custom_field.update!(default_value:) - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_field(custom_field.name, with: default_value) - end - end - end - - shared_examples 'a rich text custom field input' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - field.expect_value(expected_initial_value) - end - end - - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - field.expect_value(expected_blank_value) - end - end - - it 'shows the default value if no value is given' do - custom_field.custom_values.destroy_all - custom_field.update!(default_value:) - - overview_page.open_edit_dialog_for_section(section) - - dialog.within_async_content(close_after_yield: true) do - field.expect_value(default_value) - end - end - end - - describe 'with boolean CF' do - let(:custom_field) { boolean_project_custom_field } - let(:default_value) { false } - let(:expected_blank_value) { false } - let(:expected_initial_value) { true } - - it_behaves_like 'a custom field checkbox' - end - - describe 'with string CF' do - let(:custom_field) { string_project_custom_field } - let(:default_value) { 'Default value' } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 'Foo' } - - it_behaves_like 'a custom field input' - end - - describe 'with integer CF' do - let(:custom_field) { integer_project_custom_field } - let(:default_value) { 789 } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 123 } - - it_behaves_like 'a custom field input' - end - - describe 'with float CF' do - let(:custom_field) { float_project_custom_field } - let(:default_value) { 789.123 } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 123.456 } - - it_behaves_like 'a custom field input' - end - - describe 'with date CF' do - let(:custom_field) { date_project_custom_field } - let(:default_value) { Date.new(2026, 1, 1) } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { Date.new(2024, 1, 1) } - - it_behaves_like 'a custom field input' - end - - describe 'with text CF' do - let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - let(:default_value) { 'Default value' } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? - - it_behaves_like 'a rich text custom field input' - end - end - - describe 'with single select fields' do - let(:section) { section_for_select_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - - shared_examples 'a autocomplete single select field' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) - - field.expect_selected(expected_initial_value) - end - - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - field.expect_blank - end - - it 'filters the list based on the input' do - overview_page.open_edit_dialog_for_section(section) - - field.search(second_option) - - field.expect_option(second_option) - field.expect_no_option(first_option) - field.expect_no_option(third_option) - end - - it 'enables the user to select a single value from a list' do - overview_page.open_edit_dialog_for_section(section) - - field.search(second_option) - field.select_option(second_option) - - field.expect_selected(second_option) - - field.search(third_option) - field.select_option(third_option) - - field.expect_selected(third_option) - field.expect_not_selected(second_option) - end - - it 'clears the input if clicked on the clear button' do - overview_page.open_edit_dialog_for_section(section) - - field.clear - - field.expect_blank - end - end - - describe 'with single select list CF' do - let(:custom_field) { list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - - let(:expected_initial_value) { custom_field.custom_options.first.value } - - let(:first_option) { custom_field.custom_options.first.value } - let(:second_option) { custom_field.custom_options.second.value } - let(:third_option) { custom_field.custom_options.third.value } - - it_behaves_like 'a autocomplete single select field' - - it 'shows the default value if no value is given' do - custom_field.custom_values.destroy_all - - custom_field.custom_options.first.update!(default_value: true) - - overview_page.open_edit_dialog_for_section(section) - - field.expect_selected(custom_field.custom_options.first.value) - end - end - - describe 'with single version select list CF' do - let(:custom_field) { version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - - let(:expected_initial_value) { first_version.name } - - let(:first_option) { first_version.name } - let(:second_option) { second_version.name } - let(:third_option) { third_version.name } - - it_behaves_like 'a autocomplete single select field' - - describe 'with correct version scoping' do - let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) - end - - it 'shows only versions that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Version 1') - - field.expect_option(first_version.name) - field.expect_no_option(version_in_other_project.name) - end - end - end - - describe 'with single user select list CF' do - let(:custom_field) { user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - - let(:expected_initial_value) { member_in_project.name } - - let(:first_option) { member_in_project.name } - let(:second_option) { another_member_in_project.name } - let(:third_option) { one_more_member_in_project.name } - - it_behaves_like 'a autocomplete single select field' - - describe 'with correct user scoping' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end - - it 'shows only users that are members of the project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Member 1') - - field.expect_option(member_in_project.name) - field.expect_no_option(member_in_other_project.name) - end - end - - describe 'with support for user groups' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end - let!(:group) do - create(:group, name: 'Group 1 in project', - member_with_roles: { project => reader_role }) - end - let!(:group_in_other_project) do - create(:group, name: 'Group 1 in other project', members: [member_in_other_project], - member_with_roles: { other_project => reader_role }) - end - - it 'shows only groups that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Group 1') - - field.expect_option(group.name) - field.expect_no_option(group_in_other_project.name) - end - end - - describe 'with support for placeholder users' do - let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder User', - member_with_roles: { project => reader_role }) - end - - it 'shows the placeholder user' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Placeholder User') - - field.expect_option(placeholder_user.name) - end - end - end - end - - describe 'with multi select fields' do - let(:section) { section_for_multi_select_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - - shared_examples 'a autocomplete multi select field' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) - - field.expect_selected(*expected_initial_value) - end - - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - field.expect_blank - end - - it 'filters the list based on the input' do - overview_page.open_edit_dialog_for_section(section) - - field.search(second_option) - - field.expect_option(second_option) - field.expect_no_option(first_option) - field.expect_no_option(third_option) - end - - it 'allows to select multiple values' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - field.select_option(second_option) - field.select_option(third_option) - - field.expect_selected(second_option) - field.expect_selected(third_option) - end - - it 'allows to remove selected values' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - field.select_option(second_option) - field.select_option(third_option) - - field.deselect_option(third_option) - - field.expect_selected(second_option) - field.expect_not_selected(third_option) - end - - it 'allows to remove all selected values at once' do - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - field.select_option(second_option) - field.select_option(third_option) - - field.clear - - field.expect_not_selected(second_option) - field.expect_not_selected(third_option) - end - end - - describe 'with multi select list CF' do - let(:custom_field) { multi_list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - - let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } - - let(:first_option) { custom_field.custom_options.first.value } - let(:second_option) { custom_field.custom_options.second.value } - let(:third_option) { custom_field.custom_options.third.value } - - it_behaves_like 'a autocomplete multi select field' - - it 'shows the default value if no value is given' do - multi_list_project_custom_field.custom_values.destroy_all - - multi_list_project_custom_field.custom_options.first.update!(default_value: true) - multi_list_project_custom_field.custom_options.second.update!(default_value: true) - - overview_page.open_edit_dialog_for_section(section) - - field.expect_selected(multi_list_project_custom_field.custom_options.first.value) - field.expect_selected(multi_list_project_custom_field.custom_options.second.value) - end - end - - describe 'with multi version select list CF' do - let(:custom_field) { multi_version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - - let(:expected_initial_value) { [first_version.name, second_version.name] } - - let(:first_option) { first_version.name } - let(:second_option) { second_version.name } - let(:third_option) { third_version.name } - - it_behaves_like 'a autocomplete multi select field' - - describe 'with correct version scoping' do - let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) - end - - it 'shows only versions that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Version 1') - - field.expect_option(first_version.name) - field.expect_no_option(version_in_other_project.name) - end - end - end - - describe 'with multi user select list CF' do - let(:custom_field) { multi_user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - - let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } - - let(:first_option) { member_in_project.name } - let(:second_option) { another_member_in_project.name } - let(:third_option) { one_more_member_in_project.name } - - it_behaves_like 'a autocomplete multi select field' - - describe 'with correct user scoping' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end - - it 'shows only users that are members of the project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Member 1') - - field.expect_option(member_in_project.name) - field.expect_no_option(member_in_other_project.name) - end - end - - describe 'with support for user groups' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end - let!(:group) do - create(:group, name: 'Group 1 in project', - member_with_roles: { project => reader_role }) - end - let!(:another_group) do - create(:group, name: 'Group 2 in project', - member_with_roles: { project => reader_role }) - end - let!(:group_in_other_project) do - create(:group, name: 'Group 1 in other project', members: [member_in_other_project], - member_with_roles: { other_project => reader_role }) - end - - it 'shows only groups that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Group 1') - field.expect_option(group.name) - field.expect_no_option(group_in_other_project.name) - end - - it 'enables to select multiple user groups' do - overview_page.open_edit_dialog_for_section(section) - - field.select_option('Group 1 in project') - field.select_option('Group 2 in project') - - field.expect_selected('Group 1 in project') - field.expect_selected('Group 2 in project') - end - end - - describe 'with support for placeholder users' do - let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', - member_with_roles: { project => reader_role }) - end - let!(:another_placeholder_user) do - create(:placeholder_user, name: 'Another placeholder User', - member_with_roles: { project => reader_role }) - end - let!(:placeholder_user_in_other_project) do - create(:placeholder_user, name: 'Placeholder user in other project', - member_with_roles: { other_project => reader_role }) - end - - it 'shows only placeholder users from this project' do - overview_page.open_edit_dialog_for_section(section) - - field.search('Placeholder User') - - field.expect_option(placeholder_user.name) - field.expect_option(another_placeholder_user.name) - field.expect_no_option(placeholder_user_in_other_project.name) - end - - it 'enables to select multiple placeholder users' do - overview_page.open_edit_dialog_for_section(section) - - field.select_option(placeholder_user.name) - field.select_option(another_placeholder_user.name) - - field.expect_selected(placeholder_user.name) - field.expect_selected(another_placeholder_user.name) - end - end - end - end - end - - describe 'with correct validation behaviour' do - describe 'with input fields' do - let(:section) { section_for_input_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - - shared_examples 'a custom field input' do - it 'shows an error if the value is invalid' do - custom_field.update!(is_required: true) - custom_field.custom_values.destroy_all - - overview_page.open_edit_dialog_for_section(section) - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.blank')) - end - end - - # boolean CFs can not be validated - - describe 'with string CF' do - let(:custom_field) { string_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 3) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: 'Foooo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 3, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: 'Fo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) - end - - it 'shows an error if the value does not match the regex' do - custom_field.update!(regexp: '^[A-Z]+$') - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: 'foo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.invalid')) - end - end - - describe 'with integer CF' do - let(:custom_field) { integer_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 2) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '111') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 2)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 2, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '1') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 2)) - end - end - - describe 'with float CF' do - let(:custom_field) { float_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 4) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '1111.1') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 4)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 4, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '1.1') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 4)) - end - end - - describe 'with date CF' do - let(:custom_field) { date_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - - it_behaves_like 'a custom field input' - end - - describe 'with text CF' do - let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 3) - - overview_page.open_edit_dialog_for_section(section) - - field.set_value('Foooo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 3, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.set_value('Fo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) - end - end - end - end - - describe 'with correct updating behaviour' do - # TODO - end - end - end - end -end diff --git a/spec/support/form_fields/primerized/autocomplete_field.rb b/spec/support/form_fields/primerized/autocomplete_field.rb index ff62c7fcc86d..4873ebf5b117 100644 --- a/spec/support/form_fields/primerized/autocomplete_field.rb +++ b/spec/support/form_fields/primerized/autocomplete_field.rb @@ -20,6 +20,8 @@ def deselect_option(*values) page.find('.ng-value', text: val, visible: :all).find('.ng-value-icon').click sleep 0.25 # still required? end + field_container.find('.ng-arrow-wrapper').click # close dropdown + sleep 0.25 end def search(text) diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb index c5dd77f3b776..49168b92022c 100644 --- a/spec/support/form_fields/primerized/input_field.rb +++ b/spec/support/form_fields/primerized/input_field.rb @@ -3,7 +3,7 @@ module FormFields module Primerized class InputField < FormField - delegate :fill_in, to: :input_element + delegate :fill_in, :check, :uncheck, to: :input_element def field_container page.find(selector).first(:xpath, ".//..").first(:xpath, ".//..") From d584f09223fae8abe2c451fee2ac7357bd87d2b6 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Feb 2024 12:45:55 +0700 Subject: [PATCH 076/218] fixed icon only async dialog trigger button paddings --- .../op_primer/async_dialog_component.html.erb | 11 +++++++---- .../op_turbo/op_primer/async_dialog_component.rb | 12 ++++++++++-- .../sections/show_component.html.erb | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb index 2955f10eb7f3..430e91945115 100644 --- a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb +++ b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb @@ -5,11 +5,14 @@ title: @title, size: @size )) do |dialog| - dialog.with_show_button(**merged_button_attributes) do |button| - button.with_leading_visual_icon(icon: @button_icon) if @button_icon - @button_text + if @button_text + dialog.with_show_button(**merged_text_button_attributes) do |button| + button.with_leading_visual_icon(icon: @button_icon) if @button_icon + @button_text + end + else + dialog.with_show_button(**merged_icon_button_attributes) end - content_tag("turbo-frame", id: "#{@id}-frame", loading: :lazy, diff --git a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb index 6a8ef3ce3766..93a51f8815b8 100644 --- a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb +++ b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb @@ -32,7 +32,8 @@ class AsyncDialogComponent < ApplicationComponent include ApplicationHelper include ::OpPrimer::ComponentHelpers - def initialize(id:, src:, title:, button_icon: nil, button_text: nil, button_attributes: {}, size: :auto) + def initialize(id:, src:, title:, button_icon: nil, button_icon_label: nil, button_text: nil, button_attributes: {}, + size: :auto) super @id = id @@ -40,6 +41,7 @@ def initialize(id:, src:, title:, button_icon: nil, button_text: nil, button_att @title = title @size = size @button_icon = button_icon + @button_icon_label = button_icon_label @button_text = button_text @button_attributes = button_attributes end @@ -53,7 +55,7 @@ def stimulus_attributes } end - def merged_button_attributes + def merged_text_button_attributes stimuls_action_ref = 'click->op-turbo-op-primer-async-dialog#reinitFrame' @button_attributes[:data] = {} if @button_attributes[:data].nil? @@ -61,6 +63,12 @@ def merged_button_attributes @button_attributes end + + def merged_icon_button_attributes + merged_text_button_attributes.merge( + icon: @button_icon, 'aria-label': @button_icon_label + ) + end end end end diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 0d114a622a62..c5138dd3c860 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -16,6 +16,7 @@ size: :medium_portrait, title: @project_custom_field_section.name, button_icon: :pencil, + button_icon_label: t('edit'), button_attributes: { scheme: :invisible, data: { qa_selector: "project-custom-field-section-edit-button" } } From 82240442632acf8789607dc7e27505d7addf7fb1 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Feb 2024 14:06:16 +0700 Subject: [PATCH 077/218] adjusted switch positions as requested --- .../custom_field_row_component.html.erb | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index de2b1c0e17c6..ba1bb17b16ba 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -1,8 +1,27 @@ <%= component_wrapper do - flex_layout(align_items: :center, classes: 'op-project-custom-field', data: { + flex_layout(align_items: :center, justify_content: :space_between, classes: 'op-project-custom-field', data: { qa_selector: "project-custom-field-#{@project_custom_field.id}" }) do |custom_field_container| + custom_field_container.with_column(flex_layout: true) do |title_container| + title_container.with_column(pt: 1, mr: 2) do + render(Primer::Beta::Text.new) do + @project_custom_field.name + end + end + title_container.with_column(pt: 1, mr: 2, data: { qa_selector: "custom-field-type" } ) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize + end + end + if @project_custom_field.required? + title_container.with_column(pt: 1) do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + t("label_required") + end + end + end + end # py: 1 quick fix: prevents the row from bouncing as the toggle switch currently changes height while toggling custom_field_container.with_column(py: 1, mr: 2) do # buggy currently: @@ -24,23 +43,6 @@ status_label_position: :start )) end - custom_field_container.with_column(pt: 1, mr: 2) do - render(Primer::Beta::Text.new) do - @project_custom_field.name - end - end - custom_field_container.with_column(pt: 1, mr: 2, data: { qa_selector: "custom-field-type" } ) do - render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do - @project_custom_field.field_format.capitalize - end - end - if @project_custom_field.required? - custom_field_container.with_column(pt: 1) do - render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do - t("label_required") - end - end - end end end %> From 8685d3eb724a5af1f93f52f3efc7568d73f503b7 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Feb 2024 14:25:37 +0700 Subject: [PATCH 078/218] removed feature flag as discussed with @oliverguenther --- app/controllers/custom_fields_controller.rb | 6 +- app/helpers/custom_fields_helper.rb | 14 +- app/models/project.rb | 4 +- app/views/custom_fields/_form.html.erb | 2 +- config/initializers/menus.rb | 35 +- docker/pullpreview/docker-compose.yml | 1 - .../overviews/overviews_controller.rb | 6 - .../overview_page/dialog/inputs_spec.rb | 790 ++++++------- .../overview_page/dialog/permission_spec.rb | 42 +- .../overview_page/dialog/render_spec.rb | 143 ++- .../overview_page/dialog/update_spec.rb | 798 +++++++------ .../overview_page/dialog/validation_spec.rb | 202 ++-- .../overview_page/sidebar_spec.rb | 1052 ++++++++--------- .../settings/mapping_spec.rb | 296 +++-- 14 files changed, 1660 insertions(+), 1731 deletions(-) diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index c229492a5dbb..9a34b25614f5 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -40,7 +40,7 @@ def index @custom_fields_by_type = CustomField.all .where.not(type: 'WorkPackageCustomField') # ProjectCustomFields now managed in a different UI - .tap { |query| query.where.not(type: 'ProjectCustomField') unless OpenProject::FeatureDecisions.project_attributes_active? } + .tap { |query| query.where.not(type: 'ProjectCustomField') } .group_by { |f| f.class.name } @custom_fields_by_type['WorkPackageCustomField'] = WorkPackageCustomField.includes(:types).all @@ -80,9 +80,7 @@ def find_custom_field def check_custom_field # ProjecCustomFields now managed in a different UI - if - OpenProject::FeatureDecisions.project_attributes_active? && - (@custom_field.nil? || @custom_field.type == 'ProjectCustomField') + if @custom_field.nil? || @custom_field.type == 'ProjectCustomField' flash[:error] = 'Invalid CF type' redirect_to action: :index end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 873052d234aa..b82a181d1b43 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -28,7 +28,7 @@ module CustomFieldsHelper def custom_fields_tabs - tabs = [ + [ { name: 'WorkPackageCustomField', partial: 'custom_fields/tab', @@ -41,13 +41,6 @@ def custom_fields_tabs path: custom_fields_path(tab: :TimeEntryCustomField), label: :label_spent_time }, - - { - name: 'ProjectCustomField', - partial: 'custom_fields/tab', - path: custom_fields_path(tab: :ProjectCustomField), - label: :label_project_plural - }, { name: 'VersionCustomField', partial: 'custom_fields/tab', @@ -67,11 +60,6 @@ def custom_fields_tabs label: :label_group_plural } ] - - # ProjecCustomFields now managed in a different UI - tabs.delete_if { |tab| tab[:name] == 'ProjectCustomField' && OpenProject::FeatureDecisions.project_attributes_active? } - - tabs end # Return custom field html tag corresponding to its format diff --git a/app/models/project.rb b/app/models/project.rb index 3c088d7409ce..d92a7c22307e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -36,9 +36,7 @@ class Project < ApplicationRecord include ::Scopes::Scoped - if OpenProject::FeatureDecisions.project_attributes_active? - include Projects::ActsAsCustomizablePatches - end + include Projects::ActsAsCustomizablePatches # Maximum length for project identifiers IDENTIFIER_MAX_LENGTH = 100 diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index c9033425b5fe..c8d6cd1a10ac 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.text_field :name, required: true, container_class: '-middle' %>
- <% if @custom_field.type == 'ProjectCustomField' && OpenProject::FeatureDecisions.project_attributes_active? %> + <% if @custom_field.type == 'ProjectCustomField' %>
<%= f.select :custom_field_section_id, ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 5cacde0fa897..f3fb236594c2 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -330,25 +330,17 @@ caption: Proc.new { Workflow.model_name.human }, parent: :admin_work_packages - if OpenProject::FeatureDecisions.project_attributes_active? - menu.push :admin_projects_settings, - { controller: '/admin/settings/project_custom_fields', action: :index }, - if: Proc.new { User.current.admin? }, - caption: :label_project_plural, - icon: 'projects' - - menu.push :project_custom_fields_settings, - { controller: '/admin/settings/project_custom_fields', action: :index }, - if: Proc.new { User.current.admin? }, - caption: :label_project_attributes_plural, - parent: :admin_projects_settings - else - menu.push :admin_projects_settings, - { controller: '/admin/settings/projects_settings', action: :show }, - if: Proc.new { User.current.admin? }, - caption: :label_project_plural, - icon: 'projects' - end + menu.push :admin_projects_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_plural, + icon: 'projects' + + menu.push :project_custom_fields_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_attributes_plural, + parent: :admin_projects_settings menu.push :projects_settings, { controller: '/admin/settings/projects_settings', action: :show }, @@ -623,6 +615,7 @@ project_menu_items = { general: :label_information_plural, + project_custom_fields: :label_project_attributes_plural, modules: :label_module_plural, types: :label_work_package_types, custom_fields: :label_custom_field_plural, @@ -633,10 +626,6 @@ storage: :label_required_disk_storage } - if OpenProject::FeatureDecisions.project_attributes_active? - project_menu_items = project_menu_items.to_a.insert(1, %i[project_custom_fields label_project_attributes_plural]).to_h - end - project_menu_items.each do |key, caption| menu.push :"settings_#{key}", { controller: "/projects/settings/#{key}", action: 'show' }, diff --git a/docker/pullpreview/docker-compose.yml b/docker/pullpreview/docker-compose.yml index 528af54590d9..f4879929d170 100644 --- a/docker/pullpreview/docker-compose.yml +++ b/docker/pullpreview/docker-compose.yml @@ -25,7 +25,6 @@ x-defaults: &defaults - "OPENPROJECT_RAILS__CACHE__STORE=file_store" - "RAILS_ENV=production" - "SECRET_KEY_BASE=d4e74f017910ac56c6ebad01165b7e1b37f4c9c02e9716836f8670cdc8d65a231e64e4f6416b19c8" - - "OPENPROJECT_FEATURE_PROJECT_ATTRIBUTES_ACTIVE=true" networks: - backend diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 01f7b4809278..fb60ac5f3c16 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -4,8 +4,6 @@ class OverviewsController < ::Grids::BaseInProjectController before_action :authorize before_action :jump_to_project_menu_item - before_action :check_project_attributes_feature_enabled, - only: %i[project_custom_fields_sidebar project_custom_field_section_dialog update_project_custom_values] menu_item :overview @@ -63,10 +61,6 @@ def jump_to_project_menu_item private - def check_project_attributes_feature_enabled - render_404 unless OpenProject::FeatureDecisions.project_attributes_active? - end - def pre_process(project_params) # TODO: find better solution for this: # quick fixing wrong submit format of multi user custom fields, should be fixed on the frontend side diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb index da39c3bc241a..549b10d15c47 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb @@ -34,581 +34,579 @@ let(:overview_page) { Pages::Projects::Show.new(project) } - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - before do - login_as member_with_project_edit_permissions - overview_page.visit_page - end + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end - describe 'with correct initialization and input behaviour' do - describe 'with input fields' do - let(:section) { section_for_input_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with correct initialization and input behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field checkbox' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) + shared_examples 'a custom field checkbox' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - if expected_initial_value - expect(page).to have_checked_field(custom_field.name) - else - expect(page).to have_no_checked_field(custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + if expected_initial_value + expect(page).to have_checked_field(custom_field.name) + else + expect(page).to have_no_checked_field(custom_field.name) end end + end - it 'is unchecked if no value and no default value is given' do - custom_field.custom_values.destroy_all + it 'is unchecked if no value and no default value is given' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_no_checked_field(custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) end + end - it 'shows default value if no value is given' do - custom_field.custom_values.destroy_all + it 'shows default value if no value is given' do + custom_field.custom_values.destroy_all - custom_field.update!(default_value: true) + custom_field.update!(default_value: true) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_checked_field(custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_checked_field(custom_field.name) + end - custom_field.update!(default_value: false) + custom_field.update!(default_value: false) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_no_checked_field(custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) end end + end - shared_examples 'a custom field input' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) + shared_examples 'a custom field input' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_field(custom_field.name, with: expected_initial_value) - end + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_initial_value) end + end - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_field(custom_field.name, with: expected_blank_value) - end + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_blank_value) end + end - it 'shows the default value if no value is given' do - custom_field.custom_values.destroy_all - custom_field.update!(default_value:) + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_field(custom_field.name, with: default_value) - end + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: default_value) end end + end - shared_examples 'a rich text custom field input' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) + shared_examples 'a rich text custom field input' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - field.expect_value(expected_initial_value) - end + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_initial_value) end + end - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - field.expect_value(expected_blank_value) - end + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_blank_value) end + end - it 'shows the default value if no value is given' do - custom_field.custom_values.destroy_all - custom_field.update!(default_value:) + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.within_async_content(close_after_yield: true) do - field.expect_value(default_value) - end + dialog.within_async_content(close_after_yield: true) do + field.expect_value(default_value) end end + end - describe 'with boolean CF' do - let(:custom_field) { boolean_project_custom_field } - let(:default_value) { false } - let(:expected_blank_value) { false } - let(:expected_initial_value) { true } + describe 'with boolean CF' do + let(:custom_field) { boolean_project_custom_field } + let(:default_value) { false } + let(:expected_blank_value) { false } + let(:expected_initial_value) { true } - it_behaves_like 'a custom field checkbox' - end + it_behaves_like 'a custom field checkbox' + end - describe 'with string CF' do - let(:custom_field) { string_project_custom_field } - let(:default_value) { 'Default value' } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 'Foo' } + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:default_value) { 'Default value' } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 'Foo' } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with integer CF' do - let(:custom_field) { integer_project_custom_field } - let(:default_value) { 789 } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 123 } + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:default_value) { 789 } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 123 } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with float CF' do - let(:custom_field) { float_project_custom_field } - let(:default_value) { 789.123 } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 123.456 } + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:default_value) { 789.123 } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { 123.456 } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with date CF' do - let(:custom_field) { date_project_custom_field } - let(:default_value) { Date.new(2026, 1, 1) } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { Date.new(2024, 1, 1) } + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:default_value) { Date.new(2026, 1, 1) } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { Date.new(2024, 1, 1) } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with text CF' do - let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - let(:default_value) { 'Default value' } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:default_value) { 'Default value' } + let(:expected_blank_value) { '' } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? - it_behaves_like 'a rich text custom field input' - end + it_behaves_like 'a rich text custom field input' end + end - describe 'with single select fields' do - let(:section) { section_for_select_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with single select fields' do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a autocomplete single select field' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) + shared_examples 'a autocomplete single select field' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) - field.expect_selected(expected_initial_value) - end + field.expect_selected(expected_initial_value) + end - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.expect_blank - end + field.expect_blank + end - it 'filters the list based on the input' do - overview_page.open_edit_dialog_for_section(section) + it 'filters the list based on the input' do + overview_page.open_edit_dialog_for_section(section) - field.search(second_option) + field.search(second_option) - field.expect_option(second_option) - field.expect_no_option(first_option) - field.expect_no_option(third_option) - end + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end - it 'enables the user to select a single value from a list' do - overview_page.open_edit_dialog_for_section(section) + it 'enables the user to select a single value from a list' do + overview_page.open_edit_dialog_for_section(section) - field.search(second_option) - field.select_option(second_option) + field.search(second_option) + field.select_option(second_option) - field.expect_selected(second_option) + field.expect_selected(second_option) - field.search(third_option) - field.select_option(third_option) + field.search(third_option) + field.select_option(third_option) - field.expect_selected(third_option) - field.expect_not_selected(second_option) - end + field.expect_selected(third_option) + field.expect_not_selected(second_option) + end - it 'clears the input if clicked on the clear button' do - overview_page.open_edit_dialog_for_section(section) + it 'clears the input if clicked on the clear button' do + overview_page.open_edit_dialog_for_section(section) - field.clear + field.clear - field.expect_blank - end + field.expect_blank end + end - describe 'with single select list CF' do - let(:custom_field) { list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with single select list CF' do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:expected_initial_value) { custom_field.custom_options.first.value } + let(:expected_initial_value) { custom_field.custom_options.first.value } - let(:first_option) { custom_field.custom_options.first.value } - let(:second_option) { custom_field.custom_options.second.value } - let(:third_option) { custom_field.custom_options.third.value } + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } - it_behaves_like 'a autocomplete single select field' + it_behaves_like 'a autocomplete single select field' - it 'shows the default value if no value is given' do - custom_field.custom_values.destroy_all + it 'shows the default value if no value is given' do + custom_field.custom_values.destroy_all - custom_field.custom_options.first.update!(default_value: true) + custom_field.custom_options.first.update!(default_value: true) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.expect_selected(custom_field.custom_options.first.value) - end + field.expect_selected(custom_field.custom_options.first.value) end + end - describe 'with single version select list CF' do - let(:custom_field) { version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with single version select list CF' do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:expected_initial_value) { first_version.name } + let(:expected_initial_value) { first_version.name } - let(:first_option) { first_version.name } - let(:second_option) { second_version.name } - let(:third_option) { third_version.name } + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } - it_behaves_like 'a autocomplete single select field' + it_behaves_like 'a autocomplete single select field' - describe 'with correct version scoping' do - let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) - end + describe 'with correct version scoping' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end - it 'shows only versions that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Version 1') + field.search('Version 1') - field.expect_option(first_version.name) - field.expect_no_option(version_in_other_project.name) - end + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) end end + end - describe 'with single user select list CF' do - let(:custom_field) { user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with single user select list CF' do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:expected_initial_value) { member_in_project.name } + let(:expected_initial_value) { member_in_project.name } - let(:first_option) { member_in_project.name } - let(:second_option) { another_member_in_project.name } - let(:third_option) { one_more_member_in_project.name } + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } - it_behaves_like 'a autocomplete single select field' + it_behaves_like 'a autocomplete single select field' - describe 'with correct user scoping' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end + describe 'with correct user scoping' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end - it 'shows only users that are members of the project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only users that are members of the project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Member 1') + field.search('Member 1') - field.expect_option(member_in_project.name) - field.expect_no_option(member_in_other_project.name) - end + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) end + end - describe 'with support for user groups' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end - let!(:group) do - create(:group, name: 'Group 1 in project', - member_with_roles: { project => reader_role }) - end - let!(:group_in_other_project) do - create(:group, name: 'Group 1 in other project', members: [member_in_other_project], - member_with_roles: { other_project => reader_role }) - end + describe 'with support for user groups' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end - it 'shows only groups that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only groups that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Group 1') + field.search('Group 1') - field.expect_option(group.name) - field.expect_no_option(group_in_other_project.name) - end + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) end + end - describe 'with support for placeholder users' do - let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder User', - member_with_roles: { project => reader_role }) - end + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder User', + member_with_roles: { project => reader_role }) + end - it 'shows the placeholder user' do - overview_page.open_edit_dialog_for_section(section) + it 'shows the placeholder user' do + overview_page.open_edit_dialog_for_section(section) - field.search('Placeholder User') + field.search('Placeholder User') - field.expect_option(placeholder_user.name) - end + field.expect_option(placeholder_user.name) end end end + end - describe 'with multi select fields' do - let(:section) { section_for_multi_select_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a autocomplete multi select field' do - it 'shows the correct value if given' do - overview_page.open_edit_dialog_for_section(section) + shared_examples 'a autocomplete multi select field' do + it 'shows the correct value if given' do + overview_page.open_edit_dialog_for_section(section) - field.expect_selected(*expected_initial_value) - end + field.expect_selected(*expected_initial_value) + end - it 'shows a blank input if no value or default value is given' do - custom_field.custom_values.destroy_all + it 'shows a blank input if no value or default value is given' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.expect_blank - end + field.expect_blank + end - it 'filters the list based on the input' do - overview_page.open_edit_dialog_for_section(section) + it 'filters the list based on the input' do + overview_page.open_edit_dialog_for_section(section) - field.search(second_option) + field.search(second_option) - field.expect_option(second_option) - field.expect_no_option(first_option) - field.expect_no_option(third_option) - end + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end - it 'allows to select multiple values' do - custom_field.custom_values.destroy_all + it 'allows to select multiple values' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(second_option) - field.select_option(third_option) + field.select_option(second_option) + field.select_option(third_option) - field.expect_selected(second_option) - field.expect_selected(third_option) - end + field.expect_selected(second_option) + field.expect_selected(third_option) + end - it 'allows to remove selected values' do - custom_field.custom_values.destroy_all + it 'allows to remove selected values' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(second_option) - field.select_option(third_option) + field.select_option(second_option) + field.select_option(third_option) - field.deselect_option(third_option) + field.deselect_option(third_option) - field.expect_selected(second_option) - field.expect_not_selected(third_option) - end + field.expect_selected(second_option) + field.expect_not_selected(third_option) + end - it 'allows to remove all selected values at once' do - custom_field.custom_values.destroy_all + it 'allows to remove all selected values at once' do + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(second_option) - field.select_option(third_option) + field.select_option(second_option) + field.select_option(third_option) - field.clear + field.clear - field.expect_not_selected(second_option) - field.expect_not_selected(third_option) - end + field.expect_not_selected(second_option) + field.expect_not_selected(third_option) end + end - describe 'with multi select list CF' do - let(:custom_field) { multi_list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with multi select list CF' do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } + let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } - let(:first_option) { custom_field.custom_options.first.value } - let(:second_option) { custom_field.custom_options.second.value } - let(:third_option) { custom_field.custom_options.third.value } + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like 'a autocomplete multi select field' - it 'shows the default value if no value is given' do - multi_list_project_custom_field.custom_values.destroy_all + it 'shows the default value if no value is given' do + multi_list_project_custom_field.custom_values.destroy_all - multi_list_project_custom_field.custom_options.first.update!(default_value: true) - multi_list_project_custom_field.custom_options.second.update!(default_value: true) + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.expect_selected(multi_list_project_custom_field.custom_options.first.value) - field.expect_selected(multi_list_project_custom_field.custom_options.second.value) - end + field.expect_selected(multi_list_project_custom_field.custom_options.first.value) + field.expect_selected(multi_list_project_custom_field.custom_options.second.value) end + end - describe 'with multi version select list CF' do - let(:custom_field) { multi_version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with multi version select list CF' do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:expected_initial_value) { [first_version.name, second_version.name] } + let(:expected_initial_value) { [first_version.name, second_version.name] } - let(:first_option) { first_version.name } - let(:second_option) { second_version.name } - let(:third_option) { third_version.name } + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like 'a autocomplete multi select field' - describe 'with correct version scoping' do - let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) - end + describe 'with correct version scoping' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end - it 'shows only versions that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Version 1') + field.search('Version 1') - field.expect_option(first_version.name) - field.expect_no_option(version_in_other_project.name) - end + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) end end + end - describe 'with multi user select list CF' do - let(:custom_field) { multi_user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with multi user select list CF' do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } + let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } - let(:first_option) { member_in_project.name } - let(:second_option) { another_member_in_project.name } - let(:third_option) { one_more_member_in_project.name } + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like 'a autocomplete multi select field' - describe 'with correct user scoping' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end + describe 'with correct user scoping' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end - it 'shows only users that are members of the project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only users that are members of the project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Member 1') + field.search('Member 1') - field.expect_option(member_in_project.name) - field.expect_no_option(member_in_other_project.name) - end + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) end + end - describe 'with support for user groups' do - let!(:member_in_other_project) do - create(:user, - firstname: 'Member 1', - lastname: 'In other Project', - member_with_roles: { other_project => reader_role }) - end - let!(:group) do - create(:group, name: 'Group 1 in project', - member_with_roles: { project => reader_role }) - end - let!(:another_group) do - create(:group, name: 'Group 2 in project', - member_with_roles: { project => reader_role }) - end - let!(:group_in_other_project) do - create(:group, name: 'Group 1 in other project', members: [member_in_other_project], - member_with_roles: { other_project => reader_role }) - end + describe 'with support for user groups' do + let!(:member_in_other_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In other Project', + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: 'Group 2 in project', + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end - it 'shows only groups that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only groups that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Group 1') - field.expect_option(group.name) - field.expect_no_option(group_in_other_project.name) - end + field.search('Group 1') + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end - it 'enables to select multiple user groups' do - overview_page.open_edit_dialog_for_section(section) + it 'enables to select multiple user groups' do + overview_page.open_edit_dialog_for_section(section) - field.select_option('Group 1 in project') - field.select_option('Group 2 in project') + field.select_option('Group 1 in project') + field.select_option('Group 2 in project') - field.expect_selected('Group 1 in project') - field.expect_selected('Group 2 in project') - end + field.expect_selected('Group 1 in project') + field.expect_selected('Group 2 in project') end + end - describe 'with support for placeholder users' do - let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', - member_with_roles: { project => reader_role }) - end - let!(:another_placeholder_user) do - create(:placeholder_user, name: 'Another placeholder User', - member_with_roles: { project => reader_role }) - end - let!(:placeholder_user_in_other_project) do - create(:placeholder_user, name: 'Placeholder user in other project', - member_with_roles: { other_project => reader_role }) - end + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: 'Another placeholder User', + member_with_roles: { project => reader_role }) + end + let!(:placeholder_user_in_other_project) do + create(:placeholder_user, name: 'Placeholder user in other project', + member_with_roles: { other_project => reader_role }) + end - it 'shows only placeholder users from this project' do - overview_page.open_edit_dialog_for_section(section) + it 'shows only placeholder users from this project' do + overview_page.open_edit_dialog_for_section(section) - field.search('Placeholder User') + field.search('Placeholder User') - field.expect_option(placeholder_user.name) - field.expect_option(another_placeholder_user.name) - field.expect_no_option(placeholder_user_in_other_project.name) - end + field.expect_option(placeholder_user.name) + field.expect_option(another_placeholder_user.name) + field.expect_no_option(placeholder_user_in_other_project.name) + end - it 'enables to select multiple placeholder users' do - overview_page.open_edit_dialog_for_section(section) + it 'enables to select multiple placeholder users' do + overview_page.open_edit_dialog_for_section(section) - field.select_option(placeholder_user.name) - field.select_option(another_placeholder_user.name) + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) - field.expect_selected(placeholder_user.name) - field.expect_selected(another_placeholder_user.name) - end + field.expect_selected(placeholder_user.name) + field.expect_selected(another_placeholder_user.name) end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb index 2e6b353ff5b7..60c7414eaf94 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb @@ -34,33 +34,31 @@ let(:overview_page) { Pages::Projects::Show.new(project) } - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - describe 'with insufficient permissions' do - # turboframe sidebar request is covered by a controller spec checking for 403 - # async dialog content request is be covered by a controller spec checking for 403 - # via spec/permissions/manage_project_custom_values_spec.rb - before do - login_as member_without_project_edit_permissions - overview_page.visit_page - end + describe 'with insufficient permissions' do + # turboframe sidebar request is covered by a controller spec checking for 403 + # async dialog content request is be covered by a controller spec checking for 403 + # via spec/permissions/manage_project_custom_values_spec.rb + before do + login_as member_without_project_edit_permissions + overview_page.visit_page + end - it 'does not show the edit buttons' do - overview_page.within_async_loaded_sidebar do - expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-edit-button']") - end + it 'does not show the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-edit-button']") end end + end - describe 'with sufficient permissions' do - before do - login_as member_with_project_edit_permissions - overview_page.visit_page - end + describe 'with sufficient permissions' do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end - it 'shows the edit buttons' do - overview_page.within_async_loaded_sidebar do - expect(page).to have_css("[data-qa-selector='project-custom-field-section-edit-button']", count: 3) - end + it 'shows the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_css("[data-qa-selector='project-custom-field-section-edit-button']", count: 3) end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb index 0afdd15017ca..86c4772d16fa 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb @@ -33,113 +33,110 @@ include_context 'with seeded projects, members and project custom fields' let(:overview_page) { Pages::Projects::Show.new(project) } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - before do - login_as member_with_project_edit_permissions - overview_page.visit_page - end - - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end - it 'opens a dialog showing inputs for project custom fields of a specific section' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) + it 'opens a dialog showing inputs for project custom fields of a specific section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) - dialog.expect_open - end + dialog.expect_open + end - it 'renders the dialog body asynchronically' do - expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) + it 'renders the dialog body asynchronically' do + expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) - overview_page.open_edit_dialog_for_section(section_for_input_fields) + overview_page.open_edit_dialog_for_section(section_for_input_fields) - expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible) - end + expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible) + end - it 'can be closed via close icon or cancel button' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) + it 'can be closed via close icon or cancel button' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) - dialog.close_via_icon + dialog.close_via_icon - dialog.expect_closed + dialog.expect_closed - overview_page.open_edit_dialog_for_section(section_for_input_fields) + overview_page.open_edit_dialog_for_section(section_for_input_fields) - dialog.close_via_button + dialog.close_via_button - dialog.expect_closed - end + dialog.expect_closed + end - it 'shows only the project custom fields of the specific section within the dialog' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) + it 'shows only the project custom fields of the specific section within the dialog' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) - dialog.within_async_content(close_after_yield: true) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if input_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if input_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) end end + end - dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) - overview_page.open_edit_dialog_for_section(section_for_select_fields) + overview_page.open_edit_dialog_for_section(section_for_select_fields) - dialog.within_async_content(close_after_yield: true) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if select_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) end end + end - dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_multi_select_fields) + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_multi_select_fields) - overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) + overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) - dialog.within_async_content(close_after_yield: true) do - (input_fields + select_fields + multi_select_fields).each do |project_custom_field| - if multi_select_fields.include?(project_custom_field) - expect(page).to have_content(project_custom_field.name) - else - expect(page).to have_no_content(project_custom_field.name) - end + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if multi_select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) end end end + end - it 'shows the inputs in the correct order defined by the position of project custom field in a section' do - overview_page.open_edit_dialog_for_section(section_for_input_fields) + it 'shows the inputs in the correct order defined by the position of project custom field in a section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) - dialog.within_async_content(close_after_yield: true) do - containers = dialog.input_containers + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers - expect(containers[0].text).to include('Boolean field') - expect(containers[1].text).to include('String field') - expect(containers[2].text).to include('Integer field') - expect(containers[3].text).to include('Float field') - expect(containers[4].text).to include('Date field') - expect(containers[5].text).to include('Text field') - end + expect(containers[0].text).to include('Boolean field') + expect(containers[1].text).to include('String field') + expect(containers[2].text).to include('Integer field') + expect(containers[3].text).to include('Float field') + expect(containers[4].text).to include('Date field') + expect(containers[5].text).to include('Text field') + end - boolean_project_custom_field.move_to_bottom + boolean_project_custom_field.move_to_bottom - overview_page.open_edit_dialog_for_section(section_for_input_fields) + overview_page.open_edit_dialog_for_section(section_for_input_fields) - dialog.within_async_content(close_after_yield: true) do - containers = dialog.input_containers + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers - expect(containers[0].text).to include('String field') - expect(containers[1].text).to include('Integer field') - expect(containers[2].text).to include('Float field') - expect(containers[3].text).to include('Date field') - expect(containers[4].text).to include('Text field') - expect(containers[5].text).to include('Boolean field') - end + expect(containers[0].text).to include('String field') + expect(containers[1].text).to include('Integer field') + expect(containers[2].text).to include('Float field') + expect(containers[3].text).to include('Date field') + expect(containers[4].text).to include('Text field') + expect(containers[5].text).to include('Boolean field') end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb index 2a1e10cf08bc..ad32844d47cd 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -34,605 +34,603 @@ let(:overview_page) { Pages::Projects::Show.new(project) } - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - before do - login_as member_with_project_edit_permissions - overview_page.visit_page - end + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end - describe 'with correct updating behaviour' do - describe 'with input fields' do - let(:section) { section_for_input_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with correct updating behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field checkbox' do - it 'sets the value to true if checked' do - custom_field.custom_values.destroy_all + shared_examples 'a custom field checkbox' do + it 'sets the value to true if checked' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Not set yet" - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Not set yet" + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.check + field.check - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Yes" - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" end + end - it 'sets the value to false if unchecked' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Yes" - end + it 'sets the value to false if unchecked' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.uncheck + field.uncheck - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "No" - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "No" end + end - it 'does not change the value if untouched' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Yes" - end + it 'does not change the value if untouched' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - # don't touch the input + # don't touch the input - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Yes" - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" end end + end - shared_examples 'a custom field input' do - it 'saves the value properly' do - custom_field.custom_values.destroy_all + shared_examples 'a custom field input' do + it 'saves the value properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Not set yet" - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Not set yet" + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: update_value) + field.fill_in(with: update_value) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content expected_updated_value - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_updated_value end + end - it 'does not change the value if untouched' do - overview_page.visit_page + it 'does not change the value if untouched' do + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content expected_initial_value - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - # don't touch the input + # don't touch the input - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content expected_initial_value - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value end + end - it 'removes the value properly' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content expected_initial_value - end + it 'removes the value properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: '') + field.fill_in(with: '') - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Not set yet" - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Not set yet" end end + end - shared_examples 'a rich text custom field input' do - it 'saves the value properly' do - custom_field.custom_values.destroy_all + shared_examples 'a rich text custom field input' do + it 'saves the value properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text(expected_updated_value) - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text(expected_updated_value) + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.set_value(update_value) + field.set_value(update_value) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text(expected_updated_value) - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text(expected_updated_value) end + end - it 'does not change the value if untouched' do - overview_page.visit_page + it 'does not change the value if untouched' do + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content expected_initial_value - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - # don't touch the input + # don't touch the input - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content expected_initial_value - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value end + end - it 'removes the value properly' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text(expected_initial_value) - end + it 'removes the value properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text(expected_initial_value) + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.set_value('') + field.set_value('') - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text(expected_initial_value) - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text(expected_initial_value) end end + end - describe 'with boolean CF' do - let(:custom_field) { boolean_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { true } + describe 'with boolean CF' do + let(:custom_field) { boolean_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { true } - it_behaves_like 'a custom field checkbox' - end + it_behaves_like 'a custom field checkbox' + end - describe 'with string CF' do - let(:custom_field) { string_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { 'Foo' } - let(:update_value) { 'Bar' } - let(:expected_updated_value) { update_value } + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 'Foo' } + let(:update_value) { 'Bar' } + let(:expected_updated_value) { update_value } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with integer CF' do - let(:custom_field) { integer_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { 123 } - let(:update_value) { 456 } - let(:expected_updated_value) { update_value } + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 123 } + let(:update_value) { 456 } + let(:expected_updated_value) { update_value } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with float CF' do - let(:custom_field) { float_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { 123.456 } - let(:update_value) { 456.789 } - let(:expected_updated_value) { update_value } + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 123.456 } + let(:update_value) { 456.789 } + let(:expected_updated_value) { update_value } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with date CF' do - let(:custom_field) { date_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { '01/01/2024' } - let(:update_value) { Date.new(2024, 1, 2) } - let(:expected_updated_value) { '01/02/2024' } + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { '01/01/2024' } + let(:update_value) { Date.new(2024, 1, 2) } + let(:expected_updated_value) { '01/02/2024' } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with text CF' do - let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? - let(:update_value) { "Dolor\n\nsit" } - let(:expected_updated_value) { "Dolor\nsit" } + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? + let(:update_value) { "Dolor\n\nsit" } + let(:expected_updated_value) { "Dolor\nsit" } - it_behaves_like 'a rich text custom field input' - end + it_behaves_like 'a rich text custom field input' end + end - describe 'with select fields' do - let(:section) { section_for_select_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with select fields' do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a select field' do - it 'saves the value properly' do - custom_field.custom_values.destroy_all + shared_examples 'a select field' do + it 'saves the value properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(first_option) + field.select_option(first_option) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option end + end - it 'does not change the value if untouched' do - overview_page.visit_page + it 'does not change the value if untouched' do + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - # don't touch the input + # don't touch the input - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option end + end - it 'removes the value properly' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - end + it 'removes the value properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.clear + field.clear - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option end end + end - describe 'with list CF' do - let(:custom_field) { list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with list CF' do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:first_option) { custom_field.custom_options.first.value } + let(:first_option) { custom_field.custom_options.first.value } - it_behaves_like 'a select field' - end + it_behaves_like 'a select field' + end - describe 'with version select CF' do - let(:custom_field) { version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with version select CF' do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:first_option) { first_version.name } + let(:first_option) { first_version.name } - it_behaves_like 'a select field' - end + it_behaves_like 'a select field' + end - describe 'with user select CF' do - let(:custom_field) { user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with user select CF' do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:first_option) { member_in_project.name } + let(:first_option) { member_in_project.name } - it_behaves_like 'a select field' + it_behaves_like 'a select field' - describe 'with support for user groups' do - let!(:group) do - create(:group, name: 'Group 1 in project', - member_with_roles: { project => reader_role }) - end + describe 'with support for user groups' do + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end - it 'saves selected user group properly' do - custom_field.custom_values.destroy_all + it 'saves selected user group properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(group.name) + field.select_option(group.name) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text group.name - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text group.name end end + end - describe 'with support for placeholder users' do - let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', - member_with_roles: { project => reader_role }) - end + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end - it 'saves selected placeholer user properly' do - custom_field.custom_values.destroy_all + it 'saves selected placeholer user properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(placeholder_user.name) + field.select_option(placeholder_user.name) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text placeholder_user.name - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text placeholder_user.name end end end end + end - describe 'with multi select fields' do - let(:section) { section_for_multi_select_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a autocomplete multi select field' do - it 'saves single selected values properly' do - custom_field.custom_values.destroy_all + shared_examples 'a autocomplete multi select field' do + it 'saves single selected values properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(first_option) + field.select_option(first_option) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option end + end - it 'saves multi selected values properly' do - custom_field.custom_values.destroy_all + it 'saves multi selected values properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text first_option - expect(page).to have_no_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(first_option) - field.select_option(second_option) + field.select_option(first_option) + field.select_option(second_option) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option end + end - it 'removes deselected values properly' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_text second_option - end + it 'removes deselected values properly' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.deselect_option(first_option) + field.deselect_option(first_option) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text first_option - expect(page).to have_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_text second_option end + end - it 'does not remove values when not touching the init values' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_text second_option - end + it 'does not remove values when not touching the init values' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - # don't touch the values + # don't touch the values - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option end + end - it 'removes all values when clearing the input' do - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_text second_option - end + it 'removes all values when clearing the input' do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.clear + field.clear - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_no_text first_option - expect(page).to have_no_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option end + end - it 'adds values properly to init values' do - custom_field.custom_values.last.destroy + it 'adds values properly to init values' do + custom_field.custom_values.last.destroy - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_no_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_no_text second_option + end - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(second_option) + field.select_option(second_option) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text first_option - expect(page).to have_text second_option - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option end end + end - describe 'with multi select list CF' do - let(:custom_field) { multi_list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with multi select list CF' do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:first_option) { custom_field.custom_options.first.value } - let(:second_option) { custom_field.custom_options.second.value } + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } - it_behaves_like 'a autocomplete multi select field' - end + it_behaves_like 'a autocomplete multi select field' + end - describe 'with multi version select list CF' do - let(:custom_field) { multi_version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with multi version select list CF' do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:first_option) { first_version.name } - let(:second_option) { second_version.name } + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } - it_behaves_like 'a autocomplete multi select field' - end + it_behaves_like 'a autocomplete multi select field' + end - describe 'with multi user select list CF' do - let(:custom_field) { multi_user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + describe 'with multi user select list CF' do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - let(:first_option) { member_in_project.name } - let(:second_option) { another_member_in_project.name } + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like 'a autocomplete multi select field' - describe 'with support for user groups' do - let!(:group) do - create(:group, name: 'Group 1 in project', - member_with_roles: { project => reader_role }) - end - let!(:another_group) do - create(:group, name: 'Group 2 in project', - member_with_roles: { project => reader_role }) - end + describe 'with support for user groups' do + let!(:group) do + create(:group, name: 'Group 1 in project', + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: 'Group 2 in project', + member_with_roles: { project => reader_role }) + end - it 'saves selected user groups properly' do - custom_field.custom_values.destroy_all + it 'saves selected user groups properly' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(group.name) - field.select_option(another_group.name) + field.select_option(group.name) + field.select_option(another_group.name) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text group.name - expect(page).to have_text another_group.name - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text group.name + expect(page).to have_text another_group.name end end + end - describe 'with support for placeholder users' do - let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', - member_with_roles: { project => reader_role }) - end - let!(:another_placeholder_user) do - create(:placeholder_user, name: 'Another placeholder User', - member_with_roles: { project => reader_role }) - end + describe 'with support for placeholder users' do + let!(:placeholder_user) do + create(:placeholder_user, name: 'Placeholder user', + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: 'Another placeholder User', + member_with_roles: { project => reader_role }) + end - it 'shows only placeholder users from this project' do - custom_field.custom_values.destroy_all + it 'shows only placeholder users from this project' do + custom_field.custom_values.destroy_all - overview_page.visit_page + overview_page.visit_page - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.select_option(placeholder_user.name) - field.select_option(another_placeholder_user.name) + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) - dialog.submit - dialog.expect_closed + dialog.submit + dialog.expect_closed - overview_page.within_custom_field_container(custom_field) do - expect(page).to have_text placeholder_user.name - expect(page).to have_text another_placeholder_user.name - end + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text placeholder_user.name + expect(page).to have_text another_placeholder_user.name end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb index 460897129360..3d8081008f5b 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -34,173 +34,171 @@ let(:overview_page) { Pages::Projects::Show.new(project) } - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - before do - login_as member_with_project_edit_permissions - overview_page.visit_page - end + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end - describe 'with correct validation behaviour' do - describe 'with input fields' do - let(:section) { section_for_input_fields } - let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + describe 'with correct validation behaviour' do + describe 'with input fields' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field input' do - it 'shows an error if the value is invalid' do - custom_field.update!(is_required: true) - custom_field.custom_values.destroy_all + shared_examples 'a custom field input' do + it 'shows an error if the value is invalid' do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.blank')) - end + field.expect_error(I18n.t('activerecord.errors.messages.blank')) end + end - # boolean CFs can not be validated + # boolean CFs can not be validated - describe 'with string CF' do - let(:custom_field) { string_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + describe 'with string CF' do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' + it_behaves_like 'a custom field input' - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 3) + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 3) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: 'Foooo') + field.fill_in(with: 'Foooo') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) + end - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 3, max_length: 5) + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 3, max_length: 5) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: 'Fo') + field.fill_in(with: 'Fo') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) + end - it 'shows an error if the value does not match the regex' do - custom_field.update!(regexp: '^[A-Z]+$') + it 'shows an error if the value does not match the regex' do + custom_field.update!(regexp: '^[A-Z]+$') - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: 'foo') + field.fill_in(with: 'foo') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.invalid')) - end + field.expect_error(I18n.t('activerecord.errors.messages.invalid')) end + end - describe 'with integer CF' do - let(:custom_field) { integer_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + describe 'with integer CF' do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' + it_behaves_like 'a custom field input' - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 2) + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 2) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: '111') + field.fill_in(with: '111') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 2)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 2)) + end - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 2, max_length: 5) + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 2, max_length: 5) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: '1') + field.fill_in(with: '1') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 2)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 2)) end + end - describe 'with float CF' do - let(:custom_field) { float_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + describe 'with float CF' do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' + it_behaves_like 'a custom field input' - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 4) + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 4) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: '1111.1') + field.fill_in(with: '1111.1') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 4)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 4)) + end - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 4, max_length: 5) + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 4, max_length: 5) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: '1.1') + field.fill_in(with: '1.1') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 4)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 4)) end + end - describe 'with date CF' do - let(:custom_field) { date_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + describe 'with date CF' do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' - end + it_behaves_like 'a custom field input' + end - describe 'with text CF' do - let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + describe 'with text CF' do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - it_behaves_like 'a custom field input' + it_behaves_like 'a custom field input' - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 3) + it 'shows an error if the value is too long' do + custom_field.update!(max_length: 3) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.set_value('Foooo') + field.set_value('Foooo') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) + end - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 3, max_length: 5) + it 'shows an error if the value is too short' do + custom_field.update!(min_length: 3, max_length: 5) - overview_page.open_edit_dialog_for_section(section) + overview_page.open_edit_dialog_for_section(section) - field.set_value('Fo') + field.set_value('Fo') - dialog.submit + dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) - end + field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index bfb559075ab9..a4afafe2a8d4 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -38,831 +38,819 @@ login_as admin end - describe 'with disabled project attributes feature', with_flag: { project_attributes: false } do - it 'does not show the project attributes sidebar' do - overview_page.visit_page + it 'does show the project attributes sidebar' do + overview_page.visit_page - within '.op-grid-page' do - expect(page).to have_no_css('#project-custom-fields-sidebar') - end + within '.op-grid-page' do + expect(page).to have_css('#project-custom-fields-sidebar') end end - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - it 'does show the project attributes sidebar' do + describe 'with correct scoping' do + it 'shows enabled project custom fields in a sidebar grouped by section' do overview_page.visit_page - within '.op-grid-page' do - expect(page).to have_css('#project-custom-fields-sidebar') - end - end - - describe 'with correct scoping' do - it 'shows enabled project custom fields in a sidebar grouped by section' do - overview_page.visit_page + overview_page.within_async_loaded_sidebar do + expect(page).to have_css('.op-project-custom-field-section-container', count: 3) - overview_page.within_async_loaded_sidebar do - expect(page).to have_css('.op-project-custom-field-section-container', count: 3) + overview_page.within_custom_field_section_container(section_for_input_fields) do + expect(page).to have_text 'Input fields' - overview_page.within_custom_field_section_container(section_for_input_fields) do - expect(page).to have_text 'Input fields' - - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'String field' - expect(page).to have_text 'Integer field' - expect(page).to have_text 'Float field' - expect(page).to have_text 'Date field' - expect(page).to have_text 'Text field' - end + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'String field' + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Float field' + expect(page).to have_text 'Date field' + expect(page).to have_text 'Text field' + end - overview_page.within_custom_field_section_container(section_for_select_fields) do - expect(page).to have_text 'Select fields' + overview_page.within_custom_field_section_container(section_for_select_fields) do + expect(page).to have_text 'Select fields' - expect(page).to have_text 'List field' - expect(page).to have_text 'Version field' - expect(page).to have_text 'User field' - end + expect(page).to have_text 'List field' + expect(page).to have_text 'Version field' + expect(page).to have_text 'User field' + end - overview_page.within_custom_field_section_container(section_for_multi_select_fields) do - expect(page).to have_text 'Multi select fields' + overview_page.within_custom_field_section_container(section_for_multi_select_fields) do + expect(page).to have_text 'Multi select fields' - expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Multi version field' - expect(page).to have_text 'Multi user field' - end + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Multi version field' + expect(page).to have_text 'Multi user field' end end + end - it 'does not show project custom fields not enabled for this project in a sidebar' do - create(:string_project_custom_field, projects: [other_project], name: 'String field enabled for other project') + it 'does not show project custom fields not enabled for this project in a sidebar' do + create(:string_project_custom_field, projects: [other_project], name: 'String field enabled for other project') - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - expect(page).to have_no_text 'String field enabled for other project' - end + overview_page.within_async_loaded_sidebar do + expect(page).to have_no_text 'String field enabled for other project' end end + end - describe 'with correct order' do - it 'shows the project custom field sections in the correct order' do - overview_page.visit_page + describe 'with correct order' do + it 'shows the project custom field sections in the correct order' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - sections = page.all('.op-project-custom-field-section-container') + overview_page.within_async_loaded_sidebar do + sections = page.all('.op-project-custom-field-section-container') - expect(sections.size).to eq(3) + expect(sections.size).to eq(3) - expect(sections[0].text).to include('Input fields') - expect(sections[1].text).to include('Select fields') - expect(sections[2].text).to include('Multi select fields') - end + expect(sections[0].text).to include('Input fields') + expect(sections[1].text).to include('Select fields') + expect(sections[2].text).to include('Multi select fields') + end - section_for_input_fields.move_to_bottom + section_for_input_fields.move_to_bottom - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - sections = page.all('.op-project-custom-field-section-container') + overview_page.within_async_loaded_sidebar do + sections = page.all('.op-project-custom-field-section-container') - expect(sections.size).to eq(3) + expect(sections.size).to eq(3) - expect(sections[0].text).to include('Select fields') - expect(sections[1].text).to include('Multi select fields') - expect(sections[2].text).to include('Input fields') - end + expect(sections[0].text).to include('Select fields') + expect(sections[1].text).to include('Multi select fields') + expect(sections[2].text).to include('Input fields') end + end - it 'shows the project custom fields in the correct order within the sections' do - overview_page.visit_page + it 'shows the project custom fields in the correct order within the sections' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_section_container(section_for_input_fields) do - fields = page.all('.op-project-custom-field-container') + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all('.op-project-custom-field-container') - expect(fields.size).to eq(6) + expect(fields.size).to eq(6) - expect(fields[0].text).to include('Boolean field') - expect(fields[1].text).to include('String field') - expect(fields[2].text).to include('Integer field') - expect(fields[3].text).to include('Float field') - expect(fields[4].text).to include('Date field') - expect(fields[5].text).to include('Text field') - end + expect(fields[0].text).to include('Boolean field') + expect(fields[1].text).to include('String field') + expect(fields[2].text).to include('Integer field') + expect(fields[3].text).to include('Float field') + expect(fields[4].text).to include('Date field') + expect(fields[5].text).to include('Text field') end + end - string_project_custom_field.move_to_bottom + string_project_custom_field.move_to_bottom - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_section_container(section_for_input_fields) do - fields = page.all('.op-project-custom-field-container') + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all('.op-project-custom-field-container') - expect(fields.size).to eq(6) + expect(fields.size).to eq(6) - expect(fields[0].text).to include('Boolean field') - expect(fields[1].text).to include('Integer field') - expect(fields[2].text).to include('Float field') - expect(fields[3].text).to include('Date field') - expect(fields[4].text).to include('Text field') - expect(fields[5].text).to include('String field') - end + expect(fields[0].text).to include('Boolean field') + expect(fields[1].text).to include('Integer field') + expect(fields[2].text).to include('Float field') + expect(fields[3].text).to include('Date field') + expect(fields[4].text).to include('Text field') + expect(fields[5].text).to include('String field') end end end + end - describe 'with correct values' do - describe 'with boolean CF' do - # it_behaves_like 'a project custom field' do - # let(subject) { boolean_project_custom_field } - # end + describe 'with correct values' do + describe 'with boolean CF' do + # it_behaves_like 'a project custom field' do + # let(subject) { boolean_project_custom_field } + # end - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'Yes' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Yes' end end end + end - describe 'with value unset by user' do - # A boolean cannot be completely unset via UI, only toggle between true and false, no blank value possible - before do - boolean_project_custom_field.custom_values.where(customized: project).first.update!(value: false) - end + describe 'with value unset by user' do + # A boolean cannot be completely unset via UI, only toggle between true and false, no blank value possible + before do + boolean_project_custom_field.custom_values.where(customized: project).first.update!(value: false) + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'No' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'No' end end end + end - describe 'with no value set by user' do - before do - boolean_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + boolean_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value for the project custom field if no value given' do - boolean_project_custom_field.update!(default_value: true) + it 'does not show the default value for the project custom field if no value given' do + boolean_project_custom_field.update!(default_value: true) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Not set yet' end + end - boolean_project_custom_field.update!(default_value: false) + boolean_project_custom_field.update!(default_value: false) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text 'Boolean field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with string CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with string CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text 'Foo' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Foo' end end end + end - describe 'with value unset by user' do - before do - string_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + string_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - string_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + string_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value for the project custom field if no value given' do - string_project_custom_field.update!(default_value: 'Bar') + it 'does not show the default value for the project custom field if no value given' do + string_project_custom_field.update!(default_value: 'Bar') - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text 'String field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with integer CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with integer CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text '123' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text '123' end end end + end - describe 'with value unset by user' do - before do - integer_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + integer_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - integer_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + integer_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value for the project custom field if no value given' do - integer_project_custom_field.update!(default_value: 456) + it 'does not show the default value for the project custom field if no value given' do + integer_project_custom_field.update!(default_value: 456) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text 'Integer field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with date CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with date CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text '01/01/2024' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text '01/01/2024' end end end + end - describe 'with value unset by user' do - before do - date_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + date_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - date_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + date_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value for the project custom field if no value given' do - date_project_custom_field.update!(default_value: Date.new(2024, 2, 2)) + it 'does not show the default value for the project custom field if no value given' do + date_project_custom_field.update!(default_value: Date.new(2024, 2, 2)) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text 'Date field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with float CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with float CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text '123.456' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text '123.456' end end end + end - describe 'with value unset by user' do - before do - float_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + float_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - float_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + float_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text 'Not set yet' end end + end - it 'dies not show the default value for the project custom field if no value given' do - float_project_custom_field.update!(default_value: 456.789) + it 'dies not show the default value for the project custom field if no value given' do + float_project_custom_field.update!(default_value: 456.789) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text 'Float field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with text CF' do - describe 'with value set by user' do - context 'with a value that is shorter than 100 characters' do - it 'shows the correct value for the project custom field if given without truncation and dialog button' do - overview_page.visit_page + describe 'with text CF' do + describe 'with value set by user' do + context 'with a value that is shorter than 100 characters' do + it 'shows the correct value for the project custom field if given without truncation and dialog button' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text "Lorem\nipsum" - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text "Lorem\nipsum" end end end + end - context 'with a value that is longer than 100 characters' do - before do - text_project_custom_field.custom_values.where(customized: project).first.update!(value: 'a' * 101) - end + context 'with a value that is longer than 100 characters' do + before do + text_project_custom_field.custom_values.where(customized: project).first.update!(value: 'a' * 101) + end - it 'shows the correct value for the project custom field if given with truncation and dialog button' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given with truncation and dialog button' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text ("#{'a' * 97}...") - expect(page).to have_text 'Expand' + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text ("#{'a' * 97}...") + expect(page).to have_text 'Expand' - click_on 'Expand' + click_on 'Expand' - within 'modal-dialog' do - expect(page).to have_text 'a' * 101 - end + within 'modal-dialog' do + expect(page).to have_text 'a' * 101 end end end end end + end - describe 'with value unset by user' do - before do - text_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + text_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - text_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + text_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value for the project custom field if no value given' do - text_project_custom_field.update!(default_value: 'Dolor sit amet') + it 'does not show the default value for the project custom field if no value given' do + text_project_custom_field.update!(default_value: 'Dolor sit amet') - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text 'Text field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with list CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with list CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text 'Option 1' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Option 1' end end end + end - describe 'with value unset by user' do - before do - list_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + list_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - list_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + list_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value for the project custom field if no value given' do - list_project_custom_field.custom_options.first.update!(default_value: true) + it 'does not show the default value for the project custom field if no value given' do + list_project_custom_field.custom_options.first.update!(default_value: true) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text 'List field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with version CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with version CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(version_project_custom_field) do - expect(page).to have_text 'Version field' - expect(page).to have_text 'Version 1' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text 'Version field' + expect(page).to have_text 'Version 1' end end end + end - describe 'with value unset by user' do - before do - version_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + version_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(version_project_custom_field) do - expect(page).to have_text 'Version field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text 'Version field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - version_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + version_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(version_project_custom_field) do - expect(page).to have_text 'Version field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text 'Version field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with user CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + describe 'with user CF' do + describe 'with value set by user' do + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(user_project_custom_field) do - expect(page).to have_text 'User field' - expect(page).to have_css('opce-principal') - expect(page).to have_text 'Member 1 In Project' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text 'User field' + expect(page).to have_css('opce-principal') + expect(page).to have_text 'Member 1 In Project' end end end + end - describe 'with value unset by user' do - before do - user_project_custom_field.custom_values.where(customized: project).first.update!(value: '') - end + describe 'with value unset by user' do + before do + user_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + end - it 'shows the correct value for the project custom field if given' do - overview_page.visit_page + it 'shows the correct value for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(user_project_custom_field) do - expect(page).to have_text 'User field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text 'User field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with no value set by user' do - before do - user_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + user_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(user_project_custom_field) do - expect(page).to have_text 'User field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text 'User field' + expect(page).to have_text 'Not set yet' end end end + end - describe 'with support for user groups' do - # TODO - end + describe 'with support for user groups' do + # TODO + end - describe 'with support for user placeholders' do - # TODO - end + describe 'with support for user placeholders' do + # TODO end + end - describe 'with multi list CF' do - describe 'with value set by user' do - it 'shows the correct values for the project custom field if given' do - overview_page.visit_page + describe 'with multi list CF' do + describe 'with value set by user' do + it 'shows the correct values for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Option 1, Option 2' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Option 1, Option 2' end end end + end - describe 'with no value set by user' do - before do - multi_list_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + multi_list_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Not set yet' end end + end - it 'does not show the default value(s) for the project custom field if no value given' do - multi_list_project_custom_field.custom_options.first.update!(default_value: true) - multi_list_project_custom_field.custom_options.second.update!(default_value: true) + it 'does not show the default value(s) for the project custom field if no value given' do + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) - overview_page.visit_page + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text 'Multi list field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with multi version CF' do - describe 'with value set by user' do - it 'shows the correct values for the project custom field if given' do - overview_page.visit_page + describe 'with multi version CF' do + describe 'with value set by user' do + it 'shows the correct values for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_version_project_custom_field) do - expect(page).to have_text 'Multi version field' - expect(page).to have_text 'Version 1, Version 2' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_version_project_custom_field) do + expect(page).to have_text 'Multi version field' + expect(page).to have_text 'Version 1, Version 2' end end end + end - describe 'with no value set by user' do - before do - multi_version_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + multi_version_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_version_project_custom_field) do - expect(page).to have_text 'Multi version field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_version_project_custom_field) do + expect(page).to have_text 'Multi version field' + expect(page).to have_text 'Not set yet' end end end end + end - describe 'with multi user CF' do - describe 'with value set by user' do - it 'shows the correct values for the project custom field if given' do - overview_page.visit_page + describe 'with multi user CF' do + describe 'with value set by user' do + it 'shows the correct values for the project custom field if given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_user_project_custom_field) do - expect(page).to have_text 'Multi user field' - expect(page).to have_css 'opce-principal', count: 2 - expect(page).to have_text 'Member 1 In Project' - expect(page).to have_text 'Member 2 In Project' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_user_project_custom_field) do + expect(page).to have_text 'Multi user field' + expect(page).to have_css 'opce-principal', count: 2 + expect(page).to have_text 'Member 1 In Project' + expect(page).to have_text 'Member 2 In Project' end end end + end - describe 'with no value set by user' do - before do - multi_user_project_custom_field.custom_values.where(customized: project).destroy_all - end + describe 'with no value set by user' do + before do + multi_user_project_custom_field.custom_values.where(customized: project).destroy_all + end - it 'shows an N/A text for the project custom field if no value given' do - overview_page.visit_page + it 'shows an N/A text for the project custom field if no value given' do + overview_page.visit_page - overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_container(multi_user_project_custom_field) do - expect(page).to have_text 'Multi user field' - expect(page).to have_text 'Not set yet' - end + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_user_project_custom_field) do + expect(page).to have_text 'Multi user field' + expect(page).to have_text 'Not set yet' end end end diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb index a3ef7368c562..f2d434d71c0b 100644 --- a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -101,20 +101,18 @@ login_as member_in_project # can edit project but is not allowed to select project custom fields end - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - it 'does not show the menu entry in the project settings menu' do - visit project_settings_general_path(project) + it 'does not show the menu entry in the project settings menu' do + visit project_settings_general_path(project) - within '#menu-sidebar' do - expect(page).to have_no_css("li[data-name='settings_project_custom_fields']") - end + within '#menu-sidebar' do + expect(page).to have_no_css("li[data-name='settings_project_custom_fields']") end + end - it 'does not show the project custom fields page' do - visit project_settings_project_custom_fields_path(project) + it 'does not show the project custom fields page' do + visit project_settings_project_custom_fields_path(project) - expect(page).to have_content('You are not authorized to access this page.') - end + expect(page).to have_content('You are not authorized to access this page.') end end @@ -123,218 +121,206 @@ login_as user_with_sufficient_permissions end - describe 'with disabled project attributes feature', with_flag: { project_attributes: false } do - it 'does not show the menu entry in the project settings menu' do - visit project_settings_general_path(project) + it 'does show the menu entry in the project settings menu' do + visit project_settings_general_path(project) - within '#menu-sidebar' do - expect(page).to have_no_css("li[data-name='settings_project_custom_fields']") - end + within '#menu-sidebar' do + expect(page).to have_css("li[data-name='settings_project_custom_fields']") end end - describe 'with enabled project attributes feature', with_flag: { project_attributes: true } do - it 'does show the menu entry in the project settings menu' do - visit project_settings_general_path(project) + it 'shows all available project custom fields with their correct mapping state' do + visit project_settings_project_custom_fields_path(project) - within '#menu-sidebar' do - expect(page).to have_css("li[data-name='settings_project_custom_fields']") + within_custom_field_section_container(section_for_input_fields) do + within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_content('Boolean field') + expect_type("Bool") + expect_unchecked_state end - end - - it 'shows all available project custom fields with their correct mapping state' do - visit project_settings_project_custom_fields_path(project) - - within_custom_field_section_container(section_for_input_fields) do - within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_content('Boolean field') - expect_type("Bool") - expect_unchecked_state - end - within_custom_field_container(string_project_custom_field) do - expect(page).to have_content('String field') - expect_type("String") - expect_unchecked_state - end + within_custom_field_container(string_project_custom_field) do + expect(page).to have_content('String field') + expect_type("String") + expect_unchecked_state end + end - within_custom_field_section_container(section_for_select_fields) do - within_custom_field_container(list_project_custom_field) do - expect(page).to have_content('List field') - expect_type("List") - expect_unchecked_state - end + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect(page).to have_content('List field') + expect_type("List") + expect_unchecked_state end + end - within_custom_field_section_container(section_for_multi_select_fields) do - within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_content('Multi list field') - expect_type("List") - expect_unchecked_state - end + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_content('Multi list field') + expect_type("List") + expect_unchecked_state end end + end - it 'toggles the mapping state of a project custom field for a specific project when clicked' do - visit project_settings_project_custom_fields_path(project) + it 'toggles the mapping state of a project custom field for a specific project when clicked' do + visit project_settings_project_custom_fields_path(project) - within_custom_field_section_container(section_for_input_fields) do - within_custom_field_container(boolean_project_custom_field) do - expect_unchecked_state + within_custom_field_section_container(section_for_input_fields) do + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state - page.find("[data-qa-selector='toggle-project-custom-field-mapping-#{boolean_project_custom_field.id}'] > button").click + page.find("[data-qa-selector='toggle-project-custom-field-mapping-#{boolean_project_custom_field.id}'] > button").click - expect_checked_state # without reloading the page - end + expect_checked_state # without reloading the page end + end - # propely persisted and visible after full page reload - visit project_settings_project_custom_fields_path(project) + # propely persisted and visible after full page reload + visit project_settings_project_custom_fields_path(project) - within_custom_field_container(boolean_project_custom_field) do - expect_checked_state - end + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end - # only for this project - visit project_settings_project_custom_fields_path(other_project) + # only for this project + visit project_settings_project_custom_fields_path(other_project) - within_custom_field_container(boolean_project_custom_field) do - expect_unchecked_state - end + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state end + end - it 'enables all mapping states of a section for a specific project when bulk action button clicked' do - visit project_settings_project_custom_fields_path(project) + it 'enables all mapping states of a section for a specific project when bulk action button clicked' do + visit project_settings_project_custom_fields_path(project) - within_custom_field_section_container(section_for_input_fields) do - page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click - within_custom_field_container(boolean_project_custom_field) do - expect_checked_state - end - within_custom_field_container(string_project_custom_field) do - expect_checked_state - end + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state end + within_custom_field_container(string_project_custom_field) do + expect_checked_state + end + end - within_custom_field_section_container(section_for_select_fields) do - within_custom_field_container(list_project_custom_field) do - expect_unchecked_state - end + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect_unchecked_state end + end - within_custom_field_section_container(section_for_multi_select_fields) do - within_custom_field_container(multi_list_project_custom_field) do - expect_unchecked_state - end + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect_unchecked_state end end + end - it 'disables all mapping states of a section for a specific project when bulk action button clicked' do - visit project_settings_project_custom_fields_path(project) + it 'disables all mapping states of a section for a specific project when bulk action button clicked' do + visit project_settings_project_custom_fields_path(project) - within_custom_field_section_container(section_for_input_fields) do - page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click - within_custom_field_container(boolean_project_custom_field) do - expect_checked_state - end - within_custom_field_container(string_project_custom_field) do - expect_checked_state - end + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + within_custom_field_container(string_project_custom_field) do + expect_checked_state end + end - within_custom_field_section_container(section_for_select_fields) do - within_custom_field_container(list_project_custom_field) do - expect_unchecked_state - end + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect_unchecked_state end + end - within_custom_field_section_container(section_for_multi_select_fields) do - within_custom_field_container(multi_list_project_custom_field) do - expect_unchecked_state - end + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect_unchecked_state end + end - within_custom_field_section_container(section_for_input_fields) do - page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click - within_custom_field_container(boolean_project_custom_field) do - expect_unchecked_state - end - within_custom_field_container(string_project_custom_field) do - expect_unchecked_state - end + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + end + within_custom_field_container(string_project_custom_field) do + expect_unchecked_state end end + end - it 'filters the project custom fields by name with given user input' do - visit project_settings_project_custom_fields_path(project) + it 'filters the project custom fields by name with given user input' do + visit project_settings_project_custom_fields_path(project) - fill_in 'project-custom-fields-mapping-filter', with: 'Boolean' + fill_in 'project-custom-fields-mapping-filter', with: 'Boolean' - within_custom_field_section_container(section_for_input_fields) do - expect(page).to have_content('Boolean field') - expect(page).to have_no_content('String field') - end + within_custom_field_section_container(section_for_input_fields) do + expect(page).to have_content('Boolean field') + expect(page).to have_no_content('String field') + end - within_custom_field_section_container(section_for_select_fields) do - expect(page).to have_no_content('List field') - end + within_custom_field_section_container(section_for_select_fields) do + expect(page).to have_no_content('List field') + end - within_custom_field_section_container(section_for_multi_select_fields) do - expect(page).to have_no_content('Multi list field') - end + within_custom_field_section_container(section_for_multi_select_fields) do + expect(page).to have_no_content('Multi list field') end + end - it 'shows the project custom field sections in the correct order' do - visit project_settings_project_custom_fields_path(project) + it 'shows the project custom field sections in the correct order' do + visit project_settings_project_custom_fields_path(project) - sections = page.all('.op-project-custom-field-section') + sections = page.all('.op-project-custom-field-section') - expect(sections.size).to eq(3) + expect(sections.size).to eq(3) - expect(sections[0].text).to include('Input fields') - expect(sections[1].text).to include('Select fields') - expect(sections[2].text).to include('Multi select fields') + expect(sections[0].text).to include('Input fields') + expect(sections[1].text).to include('Select fields') + expect(sections[2].text).to include('Multi select fields') - section_for_input_fields.move_to_bottom + section_for_input_fields.move_to_bottom - visit project_settings_project_custom_fields_path(project) + visit project_settings_project_custom_fields_path(project) - sections = page.all('.op-project-custom-field-section') + sections = page.all('.op-project-custom-field-section') - expect(sections.size).to eq(3) + expect(sections.size).to eq(3) - expect(sections[0].text).to include('Select fields') - expect(sections[1].text).to include('Multi select fields') - expect(sections[2].text).to include('Input fields') - end + expect(sections[0].text).to include('Select fields') + expect(sections[1].text).to include('Multi select fields') + expect(sections[2].text).to include('Input fields') + end - it 'shows the project custom fields in the correct order within the sections' do - visit project_settings_project_custom_fields_path(project) + it 'shows the project custom fields in the correct order within the sections' do + visit project_settings_project_custom_fields_path(project) - within_custom_field_section_container(section_for_input_fields) do - custom_fields = page.all('.op-project-custom-field') + within_custom_field_section_container(section_for_input_fields) do + custom_fields = page.all('.op-project-custom-field') - expect(custom_fields.size).to eq(2) + expect(custom_fields.size).to eq(2) - expect(custom_fields[0].text).to include('Boolean field') - expect(custom_fields[1].text).to include('String field') - end + expect(custom_fields[0].text).to include('Boolean field') + expect(custom_fields[1].text).to include('String field') + end - boolean_project_custom_field.move_to_bottom + boolean_project_custom_field.move_to_bottom - visit project_settings_project_custom_fields_path(project) + visit project_settings_project_custom_fields_path(project) - within_custom_field_section_container(section_for_input_fields) do - custom_fields = page.all('.op-project-custom-field') + within_custom_field_section_container(section_for_input_fields) do + custom_fields = page.all('.op-project-custom-field') - expect(custom_fields.size).to eq(2) + expect(custom_fields.size).to eq(2) - expect(custom_fields[0].text).to include('String field') - expect(custom_fields[1].text).to include('Boolean field') - end + expect(custom_fields[0].text).to include('String field') + expect(custom_fields[1].text).to include('Boolean field') end end end From 42f0cfe1d857a069e2915d016fa5595173baa412 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Feb 2024 15:36:38 +0700 Subject: [PATCH 079/218] fixed breadcrumb navigation --- .../admin/settings/project_custom_fields_controller.rb | 10 +++++++++- .../admin/settings/projects_settings_controller.rb | 4 ++-- .../admin/settings/project_custom_fields/edit.html.erb | 2 ++ .../admin/settings/projects_settings/show.html.erb | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 526c449e3bb5..eff4a190e162 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -40,7 +40,15 @@ class ProjectCustomFieldsController < ::Admin::SettingsController before_action :find_custom_option, only: :delete_option def default_breadcrumb - t(:label_project_attributes_plural) + if action_name == 'index' + t('label_project_attribute_plural') + else + ActionController::Base.helpers.link_to(t('label_project_attribute_plural'), admin_settings_project_custom_fields_path) + end + end + + def show_local_breadcrumb + true end def index diff --git a/app/controllers/admin/settings/projects_settings_controller.rb b/app/controllers/admin/settings/projects_settings_controller.rb index 52607951a172..11355769a88b 100644 --- a/app/controllers/admin/settings/projects_settings_controller.rb +++ b/app/controllers/admin/settings/projects_settings_controller.rb @@ -33,7 +33,7 @@ class ProjectsSettingsController < ::Admin::SettingsController before_action :validate_enabled_modules, only: :update def default_breadcrumb - t(:label_project_plural) + t(:label_project_settings) end private @@ -50,7 +50,7 @@ def validate_enabled_modules I18n.t( 'settings.projects.missing_dependencies', module: I18n.t("project_module_#{m[:name]}"), - dependencies: m[:dependencies].map { |dep|I18n.t("project_module_#{dep}") }.join(', ') + dependencies: m[:dependencies].map { |dep| I18n.t("project_module_#{dep}") }.join(', ') ) end diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb index 7b24dc2c02a6..caa93c591c0e 100644 --- a/app/views/admin/settings/project_custom_fields/edit.html.erb +++ b/app/views/admin/settings/project_custom_fields/edit.html.erb @@ -31,6 +31,8 @@ See COPYRIGHT and LICENSE files for more details. 'admin--custom-fields-format-value': @custom_field.field_format %> +<% local_assigns[:additional_breadcrumb] = @custom_field.name %> + <%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field)) %> <%= error_messages_for 'custom_field' %> diff --git a/app/views/admin/settings/projects_settings/show.html.erb b/app/views/admin/settings/projects_settings/show.html.erb index 6fe0ef956772..125c7db00caf 100644 --- a/app/views/admin/settings/projects_settings/show.html.erb +++ b/app/views/admin/settings/projects_settings/show.html.erb @@ -26,7 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: t(:label_project_plural) %> +<%= toolbar title: t(:label_project_settings) %> <%= styled_form_tag(admin_settings_projects_path, method: :patch) do %>
From 7f1933068c55da9d1fefc329b9cd7287b9afece9 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Feb 2024 15:42:06 +0700 Subject: [PATCH 080/218] fixed sidebar display condition --- frontend/src/app/features/overview/overview.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index fcfdbe3ecdc1..ddd9d5d461c1 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -14,7 +14,7 @@ export class OverviewComponent extends GridPageComponent { } protected isTurboFrameSidebarEnabled():boolean { - return this.configurationService.activeFeatureFlags.includes('projectAttributes') + return true } protected turboFrameSidebarSrc():string { From 0f39f0271842fd8adfc03e7208b6e43907e88634 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 19 Feb 2024 17:16:35 +0700 Subject: [PATCH 081/218] switched to op header component --- .../header_component.html.erb | 67 +++++++------------ 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/app/components/settings/project_custom_fields/header_component.html.erb b/app/components/settings/project_custom_fields/header_component.html.erb index 5bbe2bdcf676..e3727c36279b 100644 --- a/app/components/settings/project_custom_fields/header_component.html.erb +++ b/app/components/settings/project_custom_fields/header_component.html.erb @@ -1,51 +1,36 @@ -<%= - component_wrapper do - flex_layout do |header_container| - header_container.with_row do - flex_layout(justify_content: :space_between, align_items: :center) do |title_container| - title_container.with_column(flex: 1) do - render(Primer::Beta::Heading.new(tag: :h1)) { t('settings.project_attributes.heading') } +<%= + component_wrapper do + render Primer::OpenProject::PageHeader.new do |header| + header.with_title(variant: :default) { t('settings.project_attributes.heading') } + header.with_description { t('settings.project_attributes.heading_description') } + header.with_actions do + flex_layout(justify_content: :space_between, align_items: :center) do |action_buttons_container| + action_buttons_container.with_column(mr: 2) do + render(Primer::Beta::Button.new( + tag: :a, + href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), + scheme: :primary, + data: { turbo: "false"} + )) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_attribute') + end end - - title_container.with_column do - flex_layout(justify_content: :space_between, align_items: :center) do |action_buttons_container| - - action_buttons_container.with_column(mr: 2) do - render(Primer::Beta::Button.new( - tag: :a, - href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), - scheme: :primary, - data: { turbo: "false"} - )) do |button| - button.with_leading_visual_icon(icon: :plus) - t('settings.project_attributes.label_new_attribute') - end - end - - action_buttons_container.with_column do - render(Primer::Alpha::Dialog.new( - id: "project-custom-field-section-dialog", title: t('settings.project_attributes.label_new_section'), - size: :medium_portrait - )) do |dialog| - dialog.with_show_button('aria-label': t('settings.project_attributes.label_new_section'), scheme: :default) do |button| - button.with_leading_visual_icon(icon: :plus) - t('settings.project_attributes.label_new_section') - end - render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new()) - end + action_buttons_container.with_column do + render(Primer::Alpha::Dialog.new( + id: "project-custom-field-section-dialog", title: t('settings.project_attributes.label_new_section'), + size: :medium_portrait + )) do |dialog| + dialog.with_show_button('aria-label': t('settings.project_attributes.label_new_section'), scheme: :default) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_section') end - + render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new()) end end - end end - - header_container.with_row(mt: 1, pb: 3, mb: 2, border: :bottom) do - render(Primer::Beta::Text.new(color: :muted)) { t('settings.project_attributes.heading_description') } - end - end end %> From 3f3ffb7933332d88dc98a27ef751f7d9bab61802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 17 Feb 2024 10:43:53 +0100 Subject: [PATCH 082/218] Fix selection of principal users --- .../base/autocomplete/user_query_utils.rb | 17 ++-------------- .../inputs/multi_user_select_list.rb | 7 +++---- .../inputs/single_user_select_list.rb | 7 +++---- lib/api/v3/principals/principals_api.rb | 20 ++++++++++++++++--- .../open_project/forms/autocompleter.html.erb | 8 ++++---- .../forms/user_autocompleter.html.erb | 4 ++-- 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index 3883686df37d..8312f70c59ff 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -31,10 +31,10 @@ def user_autocomplete_options { placeholder: I18n.t(:label_user_search), resource:, - # url: ::API::V3::Utilities::PathHelper::ApiV3Path.users, + url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, filters:, searchKey: search_key, - inputValue: input_value, + inputValue: custom_input_value, focusDirectly: false, appendTo: 'body' } @@ -55,17 +55,4 @@ def filters { name: 'status', operator: '!', values: [Principal.statuses["locked"].to_s] } ] end - - def input_value - "?#{input_values_filter}" unless init_user_ids.empty? - end - - def input_values_filter - # TODO: not working yet, would work with resource "users" and simple ids, but then the options cannot be loaded - user_filter = { "type" => { "operator" => "=", "values" => ["User", "Group", "PlaceholderUser"] } } - id_filter = { "id" => { "operator" => "=", "values" => init_user_ids } } - - filters = [user_filter, id_filter] - URI.encode_www_form("filters" => filters.to_json) - end end diff --git a/app/forms/custom_fields/inputs/multi_user_select_list.rb b/app/forms/custom_fields/inputs/multi_user_select_list.rb index 323577ac732a..d9ae80d4d9ab 100644 --- a/app/forms/custom_fields/inputs/multi_user_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_user_select_list.rb @@ -30,8 +30,7 @@ class CustomFields::Inputs::MultiUserSelectList < CustomFields::Inputs::Base::Au include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| - # TODO: use user_autocompleter as seen on sharing form instead - custom_value_form.autocompleter(**input_attributes) + custom_value_form.user_autocompleter(**input_attributes) end private @@ -44,7 +43,7 @@ def autocomplete_options super.merge(user_autocomplete_options) end - def init_user_ids - @custom_values.map(&:value) + def custom_input_value + @custom_values.filter_map(&:value) end end diff --git a/app/forms/custom_fields/inputs/single_user_select_list.rb b/app/forms/custom_fields/inputs/single_user_select_list.rb index ada4dd25ff0e..fc48bb8b820f 100644 --- a/app/forms/custom_fields/inputs/single_user_select_list.rb +++ b/app/forms/custom_fields/inputs/single_user_select_list.rb @@ -30,8 +30,7 @@ class CustomFields::Inputs::SingleUserSelectList < CustomFields::Inputs::Base::A include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| - # TODO: use user_autocompleter as seen on sharing form instead - custom_value_form.autocompleter(**input_attributes) + custom_value_form.user_autocompleter(**input_attributes) end private @@ -44,7 +43,7 @@ def autocomplete_options super.merge(user_autocomplete_options) end - def init_user_ids - @custom_value.value.present? ? [@custom_value.value] : [] + def custom_input_value + @custom_value&.value end end diff --git a/lib/api/v3/principals/principals_api.rb b/lib/api/v3/principals/principals_api.rb index 09e3202bb8eb..8ddaf82484f2 100644 --- a/lib/api/v3/principals/principals_api.rb +++ b/lib/api/v3/principals/principals_api.rb @@ -32,9 +32,23 @@ module Principals class PrincipalsAPI < ::API::OpenProjectAPI resource :principals do get &::API::V3::Utilities::Endpoints::SqlFallbackedIndex - .new(model: Principal, - scope: -> { Principal.includes(:preference) }) - .mount + .new(model: Principal, + scope: -> { Principal.includes(:preference) }) + .mount + + params do + requires :id, desc: 'Principal ID' + end + route_param :id, type: Integer, desc: 'Principal ID' do + after_validation do + @principal = Principal.visible.find(params[:id]) + end + + get &::API::V3::Utilities::Endpoints::Show + .new(model: Principal, + render_representer: PrincipalRepresenterFactory) + .mount + end end end end diff --git a/lib/primer/open_project/forms/autocompleter.html.erb b/lib/primer/open_project/forms/autocompleter.html.erb index a9e0eed3aeb1..9982307c7209 100644 --- a/lib/primer/open_project/forms/autocompleter.html.erb +++ b/lib/primer/open_project/forms/autocompleter.html.erb @@ -2,8 +2,8 @@ <% if decorated_select? %> <%= render partial: '/augmented/autocomplete_select_decoration', locals: { - input_name: @autocomplete_options.fetch(:inputName) { |key| builder.field_name(@input.name) }, - input_id: @autocomplete_options.fetch(:inputId) { |key| builder.field_id(@input.name) }, + input_name: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name) }, + input_id: @autocomplete_options.fetch(:inputId) { builder.field_id(@input.name) }, select_options: select_options.map(&:to_h), multiple: @autocomplete_options.fetch(:multiple, false), key: @autocomplete_options.fetch(:resource, ''), @@ -14,8 +14,8 @@ data: @autocomplete_options.delete(:data) { {} }, inputs: @autocomplete_options.merge( classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", - inputName: @autocomplete_options.fetch(:inputName) { |key| builder.field_name(@input.name) }, - inputValue: @autocomplete_options.fetch(:inputValue) { |key| builder.object.send(@input.name) }, + inputName: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name) }, + inputValue: @autocomplete_options.fetch(:inputValue) { builder.object.send(@input.name) }, defaultData: 'true' ) %> diff --git a/lib/primer/open_project/forms/user_autocompleter.html.erb b/lib/primer/open_project/forms/user_autocompleter.html.erb index 0302fda7c1ce..cbd28864fb18 100644 --- a/lib/primer/open_project/forms/user_autocompleter.html.erb +++ b/lib/primer/open_project/forms/user_autocompleter.html.erb @@ -3,8 +3,8 @@ data: @data_attributes, inputs: @autocomplete_options.merge( classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", - inputName: builder.field_name(@input.name, multiple: @autocomplete_options[:multiple]), - inputValue: builder.object.send(@input.name), + inputName: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name, multiple: @autocomplete_options[:multiple]) }, + inputValue: @autocomplete_options.fetch(:inputValue) { builder.object.send(@input.name) }, ) %> <% end %> From 35c73795626c33452711d6c2d3a207709f889ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 20 Feb 2024 16:57:42 +0100 Subject: [PATCH 083/218] Base values on input so it's easier to pass custom options --- .../base/autocomplete/multi_value_input.rb | 18 +++++++----------- .../base/autocomplete/single_value_input.rb | 18 +----------------- .../base/autocomplete/user_query_utils.rb | 6 +++++- app/forms/custom_fields/inputs/base/input.rb | 14 ++++++++++---- app/forms/projects/custom_fields/form.rb | 9 ++++----- .../sections/edit_dialog_component.html.erb | 8 ++++++-- 6 files changed, 33 insertions(+), 40 deletions(-) diff --git a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb index fa46850be4c9..37a2304fe83d 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -26,15 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class CustomFields::Inputs::Base::Autocomplete::MultiValueInput < ApplicationForm - include CustomFields::Inputs::Base::Utils - - def initialize(custom_field:, custom_values:, object:) - @custom_field = custom_field - @custom_values = custom_values - @object = object - end - +class CustomFields::Inputs::Base::Autocomplete::MultiValueInput < CustomFields::Inputs::Base::Input def input_attributes base_input_attributes.merge( autocomplete_options:, @@ -55,11 +47,15 @@ def decorated? raise NotImplementedError end + def custom_values + @custom_values ||= @object.custom_values_for_custom_field(id: @custom_field.id) + end + def invalid? - @custom_values.any? { |custom_value| custom_value.errors.any? } + custom_values.any? { |custom_value| custom_value.errors.any? } end def validation_message - @custom_values.map { |custom_value| custom_value.errors.full_messages }.join(', ') if invalid? + custom_values.map { |custom_value| custom_value.errors.full_messages }.join(', ') if invalid? end end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb index 5472cecb9cc2..7cc17044668d 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -26,15 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class CustomFields::Inputs::Base::Autocomplete::SingleValueInput < ApplicationForm - include CustomFields::Inputs::Base::Utils - - def initialize(custom_field:, custom_value:, object:) - @custom_field = custom_field - @custom_value = custom_value - @object = object - end - +class CustomFields::Inputs::Base::Autocomplete::SingleValueInput < CustomFields::Inputs::Base::Input def input_attributes base_input_attributes.merge( autocomplete_options:, @@ -54,12 +46,4 @@ def autocomplete_options def decorated? raise NotImplementedError end - - def invalid? - @custom_value.errors.any? - end - - def validation_message - @custom_value.errors.full_messages.join(', ') if invalid? - end end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index 8312f70c59ff..75bceebed244 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -36,7 +36,7 @@ def user_autocomplete_options searchKey: search_key, inputValue: custom_input_value, focusDirectly: false, - appendTo: 'body' + appendTo: append_to } end @@ -48,6 +48,10 @@ def search_key 'any_name_attribute' end + def append_to + options.fetch(:wrapper_id, 'body') + end + def filters [ { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, diff --git a/app/forms/custom_fields/inputs/base/input.rb b/app/forms/custom_fields/inputs/base/input.rb index e8b69c876579..a578a692ef95 100644 --- a/app/forms/custom_fields/inputs/base/input.rb +++ b/app/forms/custom_fields/inputs/base/input.rb @@ -29,10 +29,12 @@ class CustomFields::Inputs::Base::Input < ApplicationForm include CustomFields::Inputs::Base::Utils - def initialize(custom_field:, custom_value:, object:) + attr_reader :options + + def initialize(custom_field:, object:, **options) @custom_field = custom_field - @custom_value = custom_value @object = object + @options = options end def input_attributes @@ -44,11 +46,15 @@ def input_attributes ) end + def custom_value + @custom_value ||= @object.custom_value_for(@custom_field.id) + end + def invalid? - @custom_value.errors.any? + custom_value.errors.any? end def validation_message - @custom_value.errors.full_messages.join(', ') if invalid? + custom_value.errors.full_messages.join(', ') if invalid? end end diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb index ef19e8bc6683..5d4c00542f7d 100644 --- a/app/forms/projects/custom_fields/form.rb +++ b/app/forms/projects/custom_fields/form.rb @@ -35,11 +35,12 @@ class Form < ApplicationForm end end - def initialize(project:, custom_field_section: nil, custom_field: nil) + def initialize(project:, custom_field_section: nil, custom_field: nil, wrapper_id: nil) super() @project = project @custom_field_section = custom_field_section @custom_field = custom_field + @wrapper_id = wrapper_id if @custom_field_section.present? && @custom_field.present? raise ArgumentError, @@ -76,8 +77,7 @@ def custom_field_input(builder, custom_field) # - rich text editor is not yet supported def single_value_custom_field_input(builder, custom_field) - custom_value = @project.custom_value_for(custom_field.id) - form_args = { custom_field:, custom_value:, object: @project } + form_args = { custom_field:, object: @project, wrapper_id: @wrapper_id } case custom_field.field_format when "string" @@ -102,8 +102,7 @@ def single_value_custom_field_input(builder, custom_field) end def multi_value_custom_field_input(builder, custom_field) - custom_values = @project.custom_values_for_custom_field(id: custom_field.id) - form_args = { custom_field:, custom_values:, object: @project } + form_args = { custom_field:, object: @project, wrapper_id: @wrapper_id } case custom_field.field_format when "list" diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index d71ff4e93ba5..a73b0457c9f5 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -1,4 +1,3 @@ - <%= content_tag("turbo-frame", id: "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}-frame") do component_wrapper(data: { qa_selector: 'async-dialog-content' }) do @@ -10,7 +9,12 @@ component_collection do |collection| # TODO: remove inline style collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, style: "max-height: 500px;")) do - render(Projects::CustomFields::Form.new(f, project: @project, custom_field_section: @project_custom_field_section)) + render(Projects::CustomFields::Form.new( + f, + project: @project, + custom_field_section: @project_custom_field_section, + wrapper_id: "#edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" + )) end collection.with_component(Primer::Alpha::Dialog::Footer.new) do component_collection do |footer_collection| From c54f1a53a8291719bb764eb1b852e4f33f8ce2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 20 Feb 2024 17:16:16 +0100 Subject: [PATCH 084/218] Allow loading multiple resources --- .../services/op-autocompleter.service.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts index d41fc08d81b3..84687ea90da2 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts @@ -8,7 +8,7 @@ import { ApiV3WorkPackagePaths } from 'core-app/core/apiv3/endpoints/work_packag import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable } from 'rxjs'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; @Injectable() @@ -32,7 +32,17 @@ export class OpAutocompleterService extends UntilDestroyedMixin { } // A method for fetching the object for a provided value using the API - public loadValue(id:string, resource:TOpAutocompleterResource):Observable { + public loadValue(id:string|string[], resource:TOpAutocompleterResource, multiple:boolean):Observable { + if (multiple) { + const calls = (id as string[]) + .map((singleId) => this.loadSingleValue(singleId, resource)); + return forkJoin(calls); + } + + return this.loadSingleValue(id as string, resource); + } + + protected loadSingleValue(id:string, resource:TOpAutocompleterResource) { return (this.apiV3Service[resource] as ApiV3ResourceCollection) .id(id) From 22def47c4c9bdd0be5a6b19c4f166e37e07cecb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 20 Feb 2024 17:16:17 +0100 Subject: [PATCH 085/218] Fix multiple values being joined in a single input --- .../op-autocompleter.component.html | 10 +++++++- .../op-autocompleter.component.ts | 16 ++++++------ .../overviews/overviews_controller.rb | 25 +------------------ 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html index b49de6521b9b..eff58f05c386 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html @@ -1,10 +1,18 @@
+ + + { this.model = resource as unknown as T; this.syncHiddenField(this.mappedInputValue); @@ -343,13 +345,13 @@ export class OpAutocompleterComponent el[this.inputBindValue as 'id']).join(','); + return this.model.map((el) => el[this.inputBindValue as 'id'] as string); } return this.model[this.inputBindValue as 'id'] as string; @@ -530,10 +532,10 @@ export class OpAutocompleterComponent Date: Tue, 20 Feb 2024 17:20:35 +0100 Subject: [PATCH 086/218] Fix edit label --- .../project_custom_fields/sections/show_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index c5138dd3c860..57be4beb9113 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -16,7 +16,7 @@ size: :medium_portrait, title: @project_custom_field_section.name, button_icon: :pencil, - button_icon_label: t('edit'), + button_icon_label: t(:label_edit), button_attributes: { scheme: :invisible, data: { qa_selector: "project-custom-field-section-edit-button" } } From b2bba8e767c79c376559cc6e2f3ffcd830077ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 20 Feb 2024 17:29:48 +0100 Subject: [PATCH 087/218] Remove user_autocompleter input in favor of just the one autocompleter allows specifying which component to use, the inputs were almost the same anyway --- .../base/autocomplete/user_query_utils.rb | 2 ++ .../inputs/multi_user_select_list.rb | 2 +- .../inputs/single_user_select_list.rb | 2 +- app/forms/work_packages/share/invitee.rb | 4 ++- .../open_project/forms/autocompleter.html.erb | 4 +-- .../forms/dsl/user_autocompleter_input.rb | 32 ------------------- .../forms/user_autocompleter.html.erb | 10 ------ .../open_project/forms/user_autocompleter.rb | 21 ------------ 8 files changed, 9 insertions(+), 68 deletions(-) delete mode 100644 lib/primer/open_project/forms/dsl/user_autocompleter_input.rb delete mode 100644 lib/primer/open_project/forms/user_autocompleter.html.erb delete mode 100644 lib/primer/open_project/forms/user_autocompleter.rb diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index 75bceebed244..50f91b973a82 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -29,6 +29,8 @@ module CustomFields::Inputs::Base::Autocomplete::UserQueryUtils def user_autocomplete_options { + component: 'opce-user-autocompleter', + defaultData: false, placeholder: I18n.t(:label_user_search), resource:, url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, diff --git a/app/forms/custom_fields/inputs/multi_user_select_list.rb b/app/forms/custom_fields/inputs/multi_user_select_list.rb index d9ae80d4d9ab..54ba380130fc 100644 --- a/app/forms/custom_fields/inputs/multi_user_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_user_select_list.rb @@ -30,7 +30,7 @@ class CustomFields::Inputs::MultiUserSelectList < CustomFields::Inputs::Base::Au include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| - custom_value_form.user_autocompleter(**input_attributes) + custom_value_form.autocompleter(**input_attributes) end private diff --git a/app/forms/custom_fields/inputs/single_user_select_list.rb b/app/forms/custom_fields/inputs/single_user_select_list.rb index fc48bb8b820f..8203c534b491 100644 --- a/app/forms/custom_fields/inputs/single_user_select_list.rb +++ b/app/forms/custom_fields/inputs/single_user_select_list.rb @@ -30,7 +30,7 @@ class CustomFields::Inputs::SingleUserSelectList < CustomFields::Inputs::Base::A include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| - custom_value_form.user_autocompleter(**input_attributes) + custom_value_form.autocompleter(**input_attributes) end private diff --git a/app/forms/work_packages/share/invitee.rb b/app/forms/work_packages/share/invitee.rb index f0fe0940af68..ad08a74c2837 100644 --- a/app/forms/work_packages/share/invitee.rb +++ b/app/forms/work_packages/share/invitee.rb @@ -28,12 +28,14 @@ module WorkPackages::Share class Invitee < ApplicationForm form do |user_invite_form| - user_invite_form.user_autocompleter( + user_invite_form.autocompleter( name: :user_id, label: I18n.t('work_package.sharing.label_search'), visually_hide_label: true, data: { 'work-packages--share--user-limit-target': 'autocompleter' }, autocomplete_options: { + component: 'opce-user-autocompleter', + defaultData: false, id: "op-share-wp-invite-autocomplete", placeholder: I18n.t('work_package.sharing.label_search_placeholder'), data: { diff --git a/lib/primer/open_project/forms/autocompleter.html.erb b/lib/primer/open_project/forms/autocompleter.html.erb index 9982307c7209..9b3c555f437e 100644 --- a/lib/primer/open_project/forms/autocompleter.html.erb +++ b/lib/primer/open_project/forms/autocompleter.html.erb @@ -10,13 +10,13 @@ append_to: @autocomplete_options.fetch(:append_to, 'body') } %> <% else %> - <%= angular_component_tag 'opce-autocompleter', + <%= angular_component_tag @autocomplete_options.fetch(:component, 'opce-autocompleter'), data: @autocomplete_options.delete(:data) { {} }, inputs: @autocomplete_options.merge( classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", inputName: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name) }, inputValue: @autocomplete_options.fetch(:inputValue) { builder.object.send(@input.name) }, - defaultData: 'true' + defaultData: @autocomplete_options.fetch(:defaultData) { true } ) %> <% end %> diff --git a/lib/primer/open_project/forms/dsl/user_autocompleter_input.rb b/lib/primer/open_project/forms/dsl/user_autocompleter_input.rb deleted file mode 100644 index 14c2f098bb96..000000000000 --- a/lib/primer/open_project/forms/dsl/user_autocompleter_input.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Primer - module OpenProject - module Forms - module Dsl - class UserAutocompleterInput < OpenProject::Forms::Dsl::AutocompleterInput - attr_reader :name, :label, :autocomplete_options, :select_options - - def initialize(name:, label:, autocomplete_options:, **system_arguments) - @name = name - @label = label - @autocomplete_options = autocomplete_options - @select_options = [] - - super(name:, label:, autocomplete_options:, **system_arguments) - - yield(self) if block_given? - end - - def to_component - UserAutocompleter.new(input: self, autocomplete_options:) - end - - def type - :user_autocompleter - end - end - end - end - end -end diff --git a/lib/primer/open_project/forms/user_autocompleter.html.erb b/lib/primer/open_project/forms/user_autocompleter.html.erb deleted file mode 100644 index cbd28864fb18..000000000000 --- a/lib/primer/open_project/forms/user_autocompleter.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%= render(FormControl.new(input: @input)) do %> - <%= angular_component_tag 'opce-user-autocompleter', - data: @data_attributes, - inputs: @autocomplete_options.merge( - classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", - inputName: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name, multiple: @autocomplete_options[:multiple]) }, - inputValue: @autocomplete_options.fetch(:inputValue) { builder.object.send(@input.name) }, - ) - %> -<% end %> diff --git a/lib/primer/open_project/forms/user_autocompleter.rb b/lib/primer/open_project/forms/user_autocompleter.rb deleted file mode 100644 index e03a85889a0b..000000000000 --- a/lib/primer/open_project/forms/user_autocompleter.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Primer - module OpenProject - module Forms - # :nodoc: - class UserAutocompleter < Primer::Forms::BaseComponent - include AngularHelper - - delegate :builder, :form, :select_options, to: :@input - - def initialize(input:, autocomplete_options:) - super() - @input = input - @data_attributes = autocomplete_options.delete(:data) { {} } - @autocomplete_options = autocomplete_options - end - end - end - end -end From 644e25b638ef98d62d638f086ff2d5bf8180cde4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 21 Feb 2024 12:48:37 +0700 Subject: [PATCH 088/218] fixed decorated select fields visibility and user select field multi value submission and clearing --- .../inputs/base/autocomplete/multi_value_input.rb | 3 ++- .../inputs/base/autocomplete/single_value_input.rb | 3 ++- .../inputs/base/autocomplete/user_query_utils.rb | 6 +----- app/forms/custom_fields/inputs/base/utils.rb | 5 +++++ app/forms/custom_fields/inputs/multi_user_select_list.rb | 9 +++++++++ .../op-autocompleter/op-autocompleter.component.html | 2 +- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb index 37a2304fe83d..76de92b4f45b 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -39,7 +39,8 @@ def input_attributes def autocomplete_options { multiple: true, - decorated: decorated? + decorated: decorated?, + append_to: } end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb index 7cc17044668d..ce6c7fcd740e 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -39,7 +39,8 @@ def input_attributes def autocomplete_options { multiple: false, - decorated: decorated? + decorated: decorated?, + append_to: } end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index 50f91b973a82..8aa771231292 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -38,7 +38,7 @@ def user_autocomplete_options searchKey: search_key, inputValue: custom_input_value, focusDirectly: false, - appendTo: append_to + appendTo: append_to # unlike for the decorated autocompleters, this option has to be passed as camelCase key here! } end @@ -50,10 +50,6 @@ def search_key 'any_name_attribute' end - def append_to - options.fetch(:wrapper_id, 'body') - end - def filters [ { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, diff --git a/app/forms/custom_fields/inputs/base/utils.rb b/app/forms/custom_fields/inputs/base/utils.rb index 84c3ea806bda..5d517a04fe04 100644 --- a/app/forms/custom_fields/inputs/base/utils.rb +++ b/app/forms/custom_fields/inputs/base/utils.rb @@ -57,4 +57,9 @@ def required? def qa_field_name @custom_field.attribute_name(:kebab_case) end + + # used within autocompleter inputs + def append_to + options.fetch(:wrapper_id, 'body') + end end diff --git a/app/forms/custom_fields/inputs/multi_user_select_list.rb b/app/forms/custom_fields/inputs/multi_user_select_list.rb index 54ba380130fc..6dc05fe908e2 100644 --- a/app/forms/custom_fields/inputs/multi_user_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_user_select_list.rb @@ -30,6 +30,15 @@ class CustomFields::Inputs::MultiUserSelectList < CustomFields::Inputs::Base::Au include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge( + scope_name_to_model: false, + name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + value: + )) + custom_value_form.autocompleter(**input_attributes) end diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html index eff58f05c386..02ee7b9d4a56 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html @@ -9,7 +9,7 @@ From 00841bed7e7de4329450028ad5c51c0b44a45774 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 21 Feb 2024 12:49:39 +0700 Subject: [PATCH 089/218] fixed specs due to primer updates --- .../project_custom_fields/overview_page/dialog/update_spec.rb | 4 ++++ .../project_custom_fields/overview_page/sidebar_spec.rb | 2 +- .../components/projects/project_custom_fields/edit_dialog.rb | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb index ad32844d47cd..1de553dc71bc 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -313,6 +313,7 @@ overview_page.open_edit_dialog_for_section(section) + field.expect_selected(first_option, second_option) # wait for proper initialization # don't touch the input dialog.submit @@ -494,6 +495,7 @@ overview_page.open_edit_dialog_for_section(section) + field.expect_selected(first_option, second_option) # wait for proper initialization # don't touch the values dialog.submit @@ -527,6 +529,8 @@ it 'adds values properly to init values' do custom_field.custom_values.last.destroy + overview_page.visit_page + overview_page.within_custom_field_container(custom_field) do expect(page).to have_text first_option expect(page).to have_no_text second_option diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index a4afafe2a8d4..c07ad169e496 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -519,7 +519,7 @@ click_on 'Expand' - within 'modal-dialog' do + within 'dialog' do expect(page).to have_text 'a' * 101 end end diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb index 26fb876e4a24..3db7c11bbb03 100644 --- a/spec/support/components/projects/project_custom_fields/edit_dialog.rb +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -46,7 +46,7 @@ def initialize(project, project_custom_field_section) end def dialog_css_selector - "modal-dialog#edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" + "dialog#edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" end def async_content_container_css_selector From 82cbacdf28517eaaf64dfe4f234936a138663fad Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 21 Feb 2024 14:14:15 +0700 Subject: [PATCH 090/218] fixing multi turbo-frame tags after stream update discovered while debugging specs --- .../sidebar_component.html.erb | 22 ++++++------ .../overviews/overviews_controller.rb | 8 +---- .../project_custom_fields_sidebar.html.erb | 34 +++++++++++++++++++ .../overview_page/dialog/update_spec.rb | 14 +++++++- 4 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb index dabcd20a885e..de5672b3b162 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -1,16 +1,14 @@ <%= - content_tag("turbo-frame", id: "project-custom-fields-sidebar") do - component_wrapper do - if available_project_custom_fields_grouped_by_section.any? - flex_layout(data: { qa_selector: "project-custom-fields-sidebar-async-content" }) do |sections_container| - available_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| - sections_container.with_row(mb: 3) do - render(ProjectCustomFields::Sections::ShowComponent.new( - project: @project, - project_custom_field_section: get_eager_loaded_project_custom_field_section(project_custom_field_section_id), - project_custom_fields: project_custom_fields - )) - end + component_wrapper do + if available_project_custom_fields_grouped_by_section.any? + flex_layout(data: { qa_selector: "project-custom-fields-sidebar-async-content" }) do |sections_container| + available_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| + sections_container.with_row(mb: 3) do + render(ProjectCustomFields::Sections::ShowComponent.new( + project: @project, + project_custom_field_section: get_eager_loaded_project_custom_field_section(project_custom_field_section_id), + project_custom_fields: project_custom_fields + )) end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 5e61a596d05c..a4626350a2ff 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -8,13 +8,7 @@ class OverviewsController < ::Grids::BaseInProjectController menu_item :overview def project_custom_fields_sidebar - render( - ProjectCustomFields::SidebarComponent.new( - project: @project, - eager_loaded_project_custom_field_sections: - ), - layout: false - ) + @eager_loaded_project_custom_field_sections = eager_loaded_project_custom_field_sections end def project_custom_field_section_dialog diff --git a/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb b/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb new file mode 100644 index 000000000000..f156037180d8 --- /dev/null +++ b/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 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. + +++#%> +<%= content_tag("turbo-frame", id: "project-custom-fields-sidebar") do %> + <%= render(ProjectCustomFields::SidebarComponent.new( + project: @project, + eager_loaded_project_custom_field_sections: @eager_loaded_project_custom_field_sections + )) %> +<% end %> \ No newline at end of file diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb index 1de553dc71bc..5da4693b0130 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -527,10 +527,22 @@ end it 'adds values properly to init values' do - custom_field.custom_values.last.destroy + custom_field.custom_values.destroy_all overview_page.visit_page + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + + dialog.submit + dialog.expect_closed + overview_page.within_custom_field_container(custom_field) do expect(page).to have_text first_option expect(page).to have_no_text second_option From fc179ada0c5c4d197306d2c1cbaa85fdbade95e2 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 21 Feb 2024 14:14:51 +0700 Subject: [PATCH 091/218] hide toggle switch loading indicator as requested by product team --- .../custom_field_row_component.html.erb | 4 +++- .../src/global_styles/openproject/_primer-adjustments.sass | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index ba1bb17b16ba..8e538fb20a36 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -27,6 +27,7 @@ # buggy currently: # small variant + status_label_position: :start leads to a small bounce while toggling # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small + # quick fix: don't display loading indicator which is causing the bounce render(Primer::Alpha::ToggleSwitch.new( src: toggle_project_settings_project_custom_fields_path( project_custom_field_project_mapping: { @@ -40,7 +41,8 @@ checked: active_in_project?, enabled: !@project_custom_field.required?, # required fields cannot be disabled size: :small, - status_label_position: :start + status_label_position: :start, + classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator", )) end end diff --git a/frontend/src/global_styles/openproject/_primer-adjustments.sass b/frontend/src/global_styles/openproject/_primer-adjustments.sass index 2b184707dc65..a84b94e232e3 100644 --- a/frontend/src/global_styles/openproject/_primer-adjustments.sass +++ b/frontend/src/global_styles/openproject/_primer-adjustments.sass @@ -55,3 +55,7 @@ ul.tabnav-tabs .breadcrumb-item.breadcrumb-item-selected a pointer-events: none + +.op-primer-adjustments__toggle-switch--hidden-loading-indicator + .ToggleSwitch-statusIcon + display: none From 91b7cd31436390a5628e172072eacb1061b4a76e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 21 Feb 2024 14:44:52 +0700 Subject: [PATCH 092/218] adjusted descriptions as requested --- config/locales/en.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index fc3ac28f2afd..a2b8d93d5320 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3029,14 +3029,14 @@ Project attributes and sections are defined in the Date: Wed, 21 Feb 2024 14:45:10 +0700 Subject: [PATCH 093/218] use op header component --- .../edit_form_header_component.html.erb | 23 ++++-------------- .../new_form_header_component.html.erb | 24 ++++--------------- 2 files changed, 9 insertions(+), 38 deletions(-) diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb index 3c223eb55472..a9e2c805fac3 100644 --- a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb +++ b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb @@ -27,24 +27,9 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - flex_layout(pb: 2, border: :bottom) do |header_container| - header_container.with_row(mb: 2, flex_layout: true, align_items: :center) do |title_row_container| - title_row_container.with_column(mr: 2) do - render(Primer::Beta::IconButton.new( - tag: :a, - href: admin_settings_project_custom_fields_path, - scheme: :invisible, - icon: "arrow-left", - size: :large, - "aria-label": t("back") - )) - end - title_row_container.with_column(mr: 2) do - render(Primer::Beta::Heading.new(tag: :h1)) { @custom_field.name } - end - end - header_container.with_row(mb: 2) do - render(Primer::Beta::Text.new(color: :muted)) { t('settings.project_attributes.edit.description') } - end + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title(variant: :medium) { @custom_field.name } + header.with_description { t('settings.project_attributes.edit.description') } + header.with_back_button(href: admin_settings_project_custom_fields_path, 'aria-label': t('button_back')) end %> diff --git a/app/components/settings/project_custom_fields/new_form_header_component.html.erb b/app/components/settings/project_custom_fields/new_form_header_component.html.erb index 19559fdeb6a0..9f551e70af12 100644 --- a/app/components/settings/project_custom_fields/new_form_header_component.html.erb +++ b/app/components/settings/project_custom_fields/new_form_header_component.html.erb @@ -26,25 +26,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> + <%= - flex_layout(pb: 2, border: :bottom) do |header_container| - header_container.with_row(mb: 2, flex_layout: true, align_items: :center) do |title_row_container| - title_row_container.with_column(mr: 2) do - render(Primer::Beta::IconButton.new( - tag: :a, - href: admin_settings_project_custom_fields_path, - scheme: :invisible, - icon: "arrow-left", - size: :large, - "aria-label": t("back") - )) - end - title_row_container.with_column(mr: 2) do - render(Primer::Beta::Heading.new(tag: :h1)) { t('settings.project_attributes.new.heading') } - end - end - header_container.with_row(mb: 2) do - render(Primer::Beta::Text.new(color: :muted)) { t('settings.project_attributes.new.description') } - end + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title(variant: :medium) { t('settings.project_attributes.new.heading') } + header.with_description { t('settings.project_attributes.new.description') } + header.with_back_button(href: admin_settings_project_custom_fields_path, 'aria-label': t('button_back')) end %> From 0789cbcbb3fe2437ead179889c6e76b48eccb3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Feb 2024 11:24:24 +0100 Subject: [PATCH 094/218] Fix detection of selected items --- .../op-autocompleter/op-autocompleter.component.ts | 12 ++++++++++-- .../user-autocompleter.component.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts index a4946630785c..705dfd744d5d 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts @@ -191,9 +191,9 @@ export class OpAutocompleterComponent boolean; - @Input() public trackByFn ? = null; + @Input() public trackByFn = this.defaultTrackByFunction(); - @Input() public compareWith ? = (a:unknown, b:unknown):boolean => a === b; + @Input() public compareWith = this.defaultCompareWithFunction(); @Input() public clearOnBackspace?:boolean = true; @@ -544,4 +544,12 @@ export class OpAutocompleterComponent unknown)|null { + return null; + } + + protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { + return (a, b) => a === b; + } } diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index 86ffcf98a837..f9c018f7fe83 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -44,6 +44,7 @@ import { UserAutocompleterTemplateComponent, } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component'; import { IUser } from 'core-app/core/state/principals/user.model'; +import { compareByAttribute, trackByProperty } from 'core-app/shared/helpers/angular/tracking-functions'; export const usersAutocompleterSelector = 'op-user-autocompleter'; @@ -134,4 +135,12 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent unknown|null { + return trackByProperty('href'); + } + + protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { + return compareByAttribute('href'); + } } From 4caefe898d6b7ecc486fff3ee015faf69c09a7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Feb 2024 11:24:31 +0100 Subject: [PATCH 095/218] Fix min-height of autocompleter --- .../src/global_styles/openproject/_primer-adjustments.sass | 4 ++++ .../sections/edit_dialog_component.html.erb | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/global_styles/openproject/_primer-adjustments.sass b/frontend/src/global_styles/openproject/_primer-adjustments.sass index a84b94e232e3..057d9f0f4040 100644 --- a/frontend/src/global_styles/openproject/_primer-adjustments.sass +++ b/frontend/src/global_styles/openproject/_primer-adjustments.sass @@ -59,3 +59,7 @@ ul.tabnav-tabs .op-primer-adjustments__toggle-switch--hidden-loading-indicator .ToggleSwitch-statusIcon display: none + +.Overlay + &-body_autocomplete_height + min-height: 300px diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb index a73b0457c9f5..a492eff70b5d 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -7,8 +7,7 @@ url: update_project_custom_values_path(project_id: @project.id, section_id: @project_custom_field_section.id), ) do |f| component_collection do |collection| - # TODO: remove inline style - collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, style: "max-height: 500px;")) do + collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, classes: "Overlay-body_autocomplete_height")) do render(Projects::CustomFields::Form.new( f, project: @project, From da2c3a9225858efb7e40fb7c9ddd72f52aa42837 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 22 Feb 2024 18:18:30 +0700 Subject: [PATCH 096/218] added specs for project custom field administration, fixed minor bugs --- .../custom_field_row_component.html.erb | 4 +- .../custom_field_row_component.rb | 7 +- .../show_component.html.erb | 6 +- .../show_component.rb | 10 +- .../header_component.html.erb | 2 +- .../project_custom_fields_controller.rb | 2 +- .../project_custom_fields/new.html.erb | 2 + .../project_custom_fields/create_spec.rb | 106 +++++++ .../admin/project_custom_fields/edit_spec.rb | 95 ++++++ .../admin/project_custom_fields/list_spec.rb | 276 ++++++++++++++++++ .../project_custom_fields/shared_context.rb | 128 ++++++++ 11 files changed, 625 insertions(+), 13 deletions(-) create mode 100644 spec/features/admin/project_custom_fields/create_spec.rb create mode 100644 spec/features/admin/project_custom_fields/edit_spec.rb create mode 100644 spec/features/admin/project_custom_fields/list_spec.rb create mode 100644 spec/features/admin/project_custom_fields/shared_context.rb diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index d376d4ebcb39..86ad4a072264 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -1,5 +1,5 @@ <%= - component_wrapper do + component_wrapper(class: "op-project-custom-field-container", data: { qa_selector: "project-custom-field-container-#{@project_custom_field.id}" }) do flex_layout(justify_content: :space_between, align_items: :center) do |main_container| main_container.with_column(flex_layout: true, align_items: :center) do |content_container| content_container.with_column(mr: 2) do @@ -28,7 +28,7 @@ end end main_container.with_column do - render(Primer::Alpha::ActionMenu.new) do |menu| + render(Primer::Alpha::ActionMenu.new(data: { qa_selector: "project-custom-field-action-menu" })) do |menu| menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_project_custom_field_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index 1edf7fb943c2..4a5ad54384df 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -44,7 +44,7 @@ def initialize(project_custom_field:) def edit_action_item(menu) menu.with_item(label: t("label_edit"), href: edit_admin_settings_project_custom_field_path(@project_custom_field), - data: { turbo: "false" }) do |item| + data: { turbo: "false", qa_selector: "project-custom-field-edit" }) do |item| item.with_leading_visual_icon(icon: :pencil) end end @@ -72,7 +72,7 @@ def move_action_item(menu, move_to, label_text, icon) menu.with_item(label: label_text, href: move_admin_settings_project_custom_field_path(@project_custom_field, move_to:), form_arguments: { - method: :put, data: { 'turbo-stream': true } + method: :put, data: { 'turbo-stream': true, qa_selector: "project-custom-field-move-#{move_to}" } }) do |item| item.with_leading_visual_icon(icon:) end @@ -83,7 +83,8 @@ def delete_action_item(menu) scheme: :danger, href: admin_settings_project_custom_field_path(@project_custom_field), form_arguments: { - method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } + method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true, + qa_selector: "project-custom-field-delete" } }) do |item| item.with_leading_visual_icon(icon: :trash) end diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index bde18fd9458a..a7878c814935 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -1,5 +1,5 @@ <%= - component_wrapper do + component_wrapper(class: "op-project-custom-field-section-container", data: { qa_selector: "project-custom-field-section-container-#{@project_custom_field_section.id}" }) do render(Primer::Beta::BorderBox.new(mt: 3, data: drag_and_drop_target_config)) do |component| component.with_header(font_weight: :bold) do flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| @@ -15,7 +15,7 @@ end section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| actions_container.with_column do - render(Primer::Alpha::ActionMenu.new) do |menu| + render(Primer::Alpha::ActionMenu.new(data: { qa_selector: "project-custom-field-section-action-menu" })) do |menu| menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_section_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) @@ -50,7 +50,7 @@ type: "ProjectCustomField", custom_field_section_id: @project_custom_field_section.id ), scheme: :secondary, - data: { turbo: "false"} + data: { turbo: "false", qa_selector: "new-project-custom-field-button" } )) do |button| button.with_leading_visual_icon(icon: :plus) t('settings.project_attributes.label_new_attribute') diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index 573cf1d4c87b..e9fc4eab56b9 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -86,7 +86,7 @@ def move_action_item(menu, move_to, label_text, icon) menu.with_item(label: label_text, href: move_admin_settings_project_custom_field_section_path(@project_custom_field_section, move_to:), form_arguments: { - method: :put, data: { 'turbo-stream': true } + method: :put, data: { 'turbo-stream': true, qa_selector: "project-custom-field-section-move-#{move_to}" } }) do |item| item.with_leading_visual_icon(icon:) end @@ -102,7 +102,10 @@ def disabled_delete_action_item(menu) def edit_action_item(menu) menu.with_item(label: t("settings.project_attributes.label_edit_section"), tag: :button, - content_arguments: { 'data-show-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}" }, + content_arguments: { + 'data-show-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}", + 'data-qa-selector': "project-custom-field-section-edit" + }, value: "") do |item| item.with_leading_visual_icon(icon: :pencil) end @@ -113,7 +116,8 @@ def delete_action_item(menu) scheme: :danger, href: admin_settings_project_custom_field_section_path(@project_custom_field_section), form_arguments: { - method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true } + method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true, + qa_selector: "project-custom-field-section-delete" } }) do |item| item.with_leading_visual_icon(icon: :trash) end diff --git a/app/components/settings/project_custom_fields/header_component.html.erb b/app/components/settings/project_custom_fields/header_component.html.erb index e3727c36279b..0b1d0169aa7c 100644 --- a/app/components/settings/project_custom_fields/header_component.html.erb +++ b/app/components/settings/project_custom_fields/header_component.html.erb @@ -11,7 +11,7 @@ tag: :a, href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), scheme: :primary, - data: { turbo: "false"} + data: { turbo: "false", qa_selector: "new-project-custom-field-button" } )) do |button| button.with_leading_visual_icon(icon: :plus) t('settings.project_attributes.label_new_attribute') diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index eff4a190e162..e5b12f7f5d70 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -74,7 +74,7 @@ def move # needs refactoring via update service @custom_field.move_to = params[:move_to]&.to_sym - update_sections_via_turbo_stream(project_custom_field_sections: @custom_field_sections) + update_sections_via_turbo_stream(project_custom_field_sections: @project_custom_field_sections) respond_with_turbo_streams end diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb index 781470cc8a99..143135f47ec5 100644 --- a/app/views/admin/settings/project_custom_fields/new.html.erb +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -31,6 +31,8 @@ See COPYRIGHT and LICENSE files for more details. 'admin--custom-fields-format-value': @custom_field.field_format %> +<% local_assigns[:additional_breadcrumb] = t('settings.project_attributes.new.heading') %> + <%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new) %> <%= error_messages_for 'custom_field' %> diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb new file mode 100644 index 000000000000..c5c03bc3ee94 --- /dev/null +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -0,0 +1,106 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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' +require_relative 'shared_context' + +RSpec.describe 'Create project custom fields', :js do + include_context 'with seeded project custom fields' + + context 'with unsufficient permissions' do + it 'is not accessible' do + login_as(non_admin) + visit new_admin_settings_project_custom_field_path + + expect(page).to have_text('You are not authorized to access this page.') + end + end + + context 'with sufficient permissions' do + before do + login_as(admin) + visit new_admin_settings_project_custom_field_path + end + + it 'shows a correct breadcrumb menu' do + within '#breadcrumb' do + expect(page).to have_link("Administration") + expect(page).to have_link('Project attributes') + expect(page).to have_text('New attribute') + end + end + + it 'allows to create a new project custom field with an associated section' do + # TODO: reuse specs for classic custom field form in order to test for other attribute settings + expect(page).to have_css('.PageHeader-title', text: 'New attribute') + + fill_in('custom_field_name', with: 'New custom field') + select(section_for_select_fields.name, from: 'custom_field_custom_field_section_id') + + click_on('Save') + + # redirects to the overview page + # the tab parameter is set as the redirect originates from the former custom field controller but does not have an effect + expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) + + expect(page).to have_text('New custom field') + + latest_custom_field = ProjectCustomField.reorder(created_at: :asc).last + + expect(latest_custom_field.name).to eq('New custom field') + expect(latest_custom_field.project_custom_field_section).to eq(section_for_select_fields) + end + + it 'allows to create a new project custom field with a prefilled section via url param' do + visit new_admin_settings_project_custom_field_path(custom_field_section_id: section_for_multi_select_fields.id) + + fill_in('custom_field_name', with: 'New custom field') + + click_on('Save') + + # redirects to the overview page + # the tab parameter is set as the redirect originates from the former custom field controller but does not have an effect + expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) + + latest_custom_field = ProjectCustomField.reorder(created_at: :asc).last + + expect(latest_custom_field.name).to eq('New custom field') + expect(latest_custom_field.project_custom_field_section).to eq(section_for_multi_select_fields) + end + + it 'prevents creating a new project custom field with an empty name' do + click_on('Save') + + # no server side validation shown, html5 validation is used + + # expect no redirect + expect(page).to have_no_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) + expect(page).to have_current_path(new_admin_settings_project_custom_field_path) + end + end +end diff --git a/spec/features/admin/project_custom_fields/edit_spec.rb b/spec/features/admin/project_custom_fields/edit_spec.rb new file mode 100644 index 000000000000..b197fbdcef44 --- /dev/null +++ b/spec/features/admin/project_custom_fields/edit_spec.rb @@ -0,0 +1,95 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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' +require_relative 'shared_context' + +RSpec.describe 'Edit project custom fields', :js do + include_context 'with seeded project custom fields' + + context 'with unsufficient permissions' do + it 'is not accessible' do + login_as(non_admin) + visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) + + expect(page).to have_text('You are not authorized to access this page.') + end + end + + context 'with sufficient permissions' do + before do + login_as(admin) + visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) + end + + it 'shows a correct breadcrumb menu' do + within '#breadcrumb' do + expect(page).to have_link("Administration") + expect(page).to have_link('Project attributes') + expect(page).to have_text(boolean_project_custom_field.name) + end + end + + it 'allows to change basic attributes and the section of the project custom field' do + # TODO: reuse specs for classic custom field form in order to test for other attribute manipulations + expect(page).to have_css('.PageHeader-title', text: boolean_project_custom_field.name) + + fill_in('custom_field_name', with: 'Updated name') + select(section_for_select_fields.name, from: 'custom_field_custom_field_section_id') + + click_on('Save') + + expect(page).to have_text('Successful update') + + expect(page).to have_css('.PageHeader-title', text: 'Updated name') + + expect(boolean_project_custom_field.reload.name).to eq("Updated name") + expect(boolean_project_custom_field.reload.project_custom_field_section).to eq(section_for_select_fields) + + within '#breadcrumb' do + expect(page).to have_link("Administration") + expect(page).to have_link('Project attributes') + expect(page).to have_text('Updated name') + end + end + + it 'prevents saving a project custom field with an empty name' do + original_name = boolean_project_custom_field.name + + fill_in('custom_field_name', with: '') + click_on('Save') + + # no server side validation shown, html5 validation is used + + expect(page).to have_no_text('Successful update') + + expect(page).to have_css('.PageHeader-title', text: original_name) + expect(boolean_project_custom_field.reload.name).to eq(original_name) + end + end +end diff --git a/spec/features/admin/project_custom_fields/list_spec.rb b/spec/features/admin/project_custom_fields/list_spec.rb new file mode 100644 index 000000000000..b01692b6606a --- /dev/null +++ b/spec/features/admin/project_custom_fields/list_spec.rb @@ -0,0 +1,276 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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' +require_relative 'shared_context' + +RSpec.describe 'List project custom fields', :js do + include_context 'with seeded project custom fields' + + context 'with unsufficient permissions' do + it 'is not accessible' do + login_as(non_admin) + visit admin_settings_project_custom_fields_path + + expect(page).to have_text('You are not authorized to access this page.') + end + end + + context 'with sufficient permissions' do + before do + login_as(admin) + visit admin_settings_project_custom_fields_path + end + + it 'shows all sections in the correct order and allows reordering via menu or drag and drop' do + containers = page.all('.op-project-custom-field-section-container') + + expect(containers[0].text).to include(section_for_input_fields.name) + expect(containers[1].text).to include(section_for_select_fields.name) + expect(containers[2].text).to include(section_for_multi_select_fields.name) + + perform_action_for_project_custom_field_section(section_for_multi_select_fields, "Move up") + + visit admin_settings_project_custom_fields_path + + containers = page.all('.op-project-custom-field-section-container') + + expect(containers[0].text).to include(section_for_input_fields.name) + expect(containers[1].text).to include(section_for_multi_select_fields.name) + expect(containers[2].text).to include(section_for_select_fields.name) + + # TODO: Add drag and drop test + end + + it 'allows to delete a section only if no project custom fields are assigned to it' do + within_project_custom_field_section_menu(section_for_multi_select_fields) do + expect(page).to have_css("button[aria-disabled='true']", text: 'Delete') + end + + multi_list_project_custom_field.destroy + multi_user_project_custom_field.destroy + multi_version_project_custom_field.destroy + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_section_menu(section_for_multi_select_fields) do + expect(page).to have_no_css("button[aria-disabled='true']", text: 'Delete') + expect(page).to have_button('Delete') + + accept_confirm do + click_on('Delete') + end + end + + expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-container-#{section_for_multi_select_fields.id}']") + end + + it 'allows to edit a section' do + within_project_custom_field_section_menu(section_for_input_fields) do + click_on('Edit title') + end + + fill_in('project_custom_field_section_name', with: 'Updated section name') + + click_on('Save') + + expect(page).to have_no_text(section_for_input_fields.name) + expect(page).to have_text('Updated section name') + end + + it 'allows to create a new section' do + within '#settings-project-custom-fields-header-component' do + click_on('dialog-show-project-custom-field-section-dialog') + end + + fill_in('project_custom_field_section_name', with: 'New section name') + + click_on('Save') + + expect(page).to have_text('New section name') + + containers = page.all('.op-project-custom-field-section-container') + + expect(containers[0].text).to include('New section name') + expect(containers[1].text).to include(section_for_input_fields.name) + expect(containers[2].text).to include(section_for_select_fields.name) + expect(containers[3].text).to include(section_for_multi_select_fields.name) + end + + describe 'managing project custom fields' do + it 'shows all custom fields in the correct order within their section and allows reordering via menu or drag and drop' do + within_project_custom_field_section_container(section_for_input_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(boolean_project_custom_field.name) + expect(containers[1].text).to include(string_project_custom_field.name) + expect(containers[2].text).to include(integer_project_custom_field.name) + expect(containers[3].text).to include(float_project_custom_field.name) + expect(containers[4].text).to include(date_project_custom_field.name) + expect(containers[5].text).to include(text_project_custom_field.name) + end + + within_project_custom_field_section_container(section_for_select_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(list_project_custom_field.name) + expect(containers[1].text).to include(version_project_custom_field.name) + expect(containers[2].text).to include(user_project_custom_field.name) + end + + within_project_custom_field_section_container(section_for_multi_select_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(multi_list_project_custom_field.name) + expect(containers[1].text).to include(multi_version_project_custom_field.name) + expect(containers[2].text).to include(multi_user_project_custom_field.name) + end + + perform_action_for_project_custom_field(multi_user_project_custom_field, "Move up") + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_section_container(section_for_multi_select_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(multi_list_project_custom_field.name) + expect(containers[1].text).to include(multi_user_project_custom_field.name) + expect(containers[2].text).to include(multi_version_project_custom_field.name) + end + + # TODO: Add drag and drop test + end + + it 'shows the number of projects using a custom field' do + within_project_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text('0 Projects') + end + + project = create(:project) + project.project_custom_fields << boolean_project_custom_field + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text('1 Project') + end + end + + it 'allows to delete a custom field' do + within_project_custom_field_menu(boolean_project_custom_field) do + accept_confirm do + click_on('Delete') + end + end + + expect(page).to have_no_css("[data-qa-selector='project-custom-field-container-#{boolean_project_custom_field.id}']") + end + + it 'redirects to the custom field edit page via menu item' do + within_project_custom_field_menu(boolean_project_custom_field) do + click_on('Edit') + end + + expect(page).to have_current_path(edit_admin_settings_project_custom_field_path(boolean_project_custom_field)) + end + + it 'redirects to the custom field edit page via click on the name of the custom field' do + within_project_custom_field_container(boolean_project_custom_field) do + click_on(boolean_project_custom_field.name) + end + + expect(page).to have_current_path(edit_admin_settings_project_custom_field_path(boolean_project_custom_field)) + end + + it 'redirects to the custom field new page via header menu button' do + page.find("[data-qa-selector='new-project-custom-field-button']").click + + expect(page).to have_current_path(new_admin_settings_project_custom_field_path(type: 'ProjectCustomField')) + end + + it 'redirects to the custom field new page via button in empty sections' do + within_project_custom_field_section_container(section_for_multi_select_fields) do + expect(page).to have_no_css("[data-qa-selector='new-project-custom-field-button']") + end + + multi_list_project_custom_field.destroy + multi_user_project_custom_field.destroy + multi_version_project_custom_field.destroy + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_section_container(section_for_multi_select_fields) do + page.find("[data-qa-selector='new-project-custom-field-button']").click + end + + expect(page).to have_current_path(new_admin_settings_project_custom_field_path( + type: 'ProjectCustomField', + custom_field_section_id: section_for_multi_select_fields.id + )) + end + end + end + + # helper methods: + + def within_project_custom_field_section_container(section, &block) + within("[data-qa-selector='project-custom-field-section-container-#{section.id}']", &block) + end + + def within_project_custom_field_section_menu(section, &block) + within_project_custom_field_section_container(section) do + page.find("[data-qa-selector='project-custom-field-section-action-menu']").click + within('anchored-position', &block) + end + end + + def perform_action_for_project_custom_field_section(section, action) + within_project_custom_field_section_menu(section) do + click_on(action) + end + sleep 0.5 # quick fix: allow the brower to process the action + end + + def within_project_custom_field_container(custom_field, &block) + within("[data-qa-selector='project-custom-field-container-#{custom_field.id}']", &block) + end + + def within_project_custom_field_menu(section, &block) + within_project_custom_field_container(section) do + page.find("[data-qa-selector='project-custom-field-action-menu']").click + within('anchored-position', &block) + end + end + + def perform_action_for_project_custom_field(custom_field, action) + within_project_custom_field_menu(custom_field) do + click_on(action) + end + sleep 0.5 # quick fix: allow the brower to process the action + end +end diff --git a/spec/features/admin/project_custom_fields/shared_context.rb b/spec/features/admin/project_custom_fields/shared_context.rb new file mode 100644 index 000000000000..ee6b3faf084b --- /dev/null +++ b/spec/features/admin/project_custom_fields/shared_context.rb @@ -0,0 +1,128 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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. +#++ + +RSpec.shared_context 'with seeded project custom fields' do + shared_let(:admin) { create(:admin) } + shared_let(:non_admin) { create(:user) } + + shared_let(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } + shared_let(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } + shared_let(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } + + let!(:boolean_project_custom_field) do + create(:boolean_project_custom_field, name: 'Boolean field', + project_custom_field_section: section_for_input_fields) + end + + let!(:string_project_custom_field) do + create(:string_project_custom_field, name: 'String field', + project_custom_field_section: section_for_input_fields) + end + + let!(:integer_project_custom_field) do + create(:integer_project_custom_field, name: 'Integer field', + project_custom_field_section: section_for_input_fields) + end + + let!(:float_project_custom_field) do + create(:float_project_custom_field, name: 'Float field', + project_custom_field_section: section_for_input_fields) + end + + let!(:date_project_custom_field) do + create(:date_project_custom_field, name: 'Date field', + project_custom_field_section: section_for_input_fields) + end + + let!(:text_project_custom_field) do + create(:text_project_custom_field, name: 'Text field', + project_custom_field_section: section_for_input_fields) + end + + let!(:list_project_custom_field) do + create(:list_project_custom_field, name: 'List field', + project_custom_field_section: section_for_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3']) + end + + let!(:version_project_custom_field) do + create(:version_project_custom_field, name: 'Version field', + project_custom_field_section: section_for_select_fields) + end + + let!(:user_project_custom_field) do + create(:user_project_custom_field, name: 'User field', + project_custom_field_section: section_for_select_fields) + end + + let!(:multi_list_project_custom_field) do + create(:list_project_custom_field, name: 'Multi list field', + project_custom_field_section: section_for_multi_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3'], + multi_value: true) + end + + let!(:multi_version_project_custom_field) do + create(:version_project_custom_field, name: 'Multi version field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + end + + let!(:multi_user_project_custom_field) do + create(:user_project_custom_field, name: 'Multi user field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + end + + let!(:input_fields) do + [ + boolean_project_custom_field, + string_project_custom_field, + integer_project_custom_field, + float_project_custom_field, + date_project_custom_field, + text_project_custom_field + ] + end + + let!(:select_fields) do + [ + list_project_custom_field, + version_project_custom_field, + user_project_custom_field + ] + end + + let!(:multi_select_fields) do + [ + multi_list_project_custom_field, + multi_version_project_custom_field, + multi_user_project_custom_field + ] + end +end From 1a99043f70553b8ac3f963b411df6236ac32d49a Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 23 Feb 2024 19:15:13 +0700 Subject: [PATCH 097/218] adjusting project model customizable specs --- .../projects/acts_as_customizable_patches.rb | 2 +- spec/models/projects/customizable_spec.rb | 167 +++++++++++------- 2 files changed, 100 insertions(+), 69 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index c630fd8fc33d..5d5cdbc9e03d 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -84,7 +84,7 @@ def available_custom_fields # overrides acts_as_customizable # in contrast to acts_as_customizable, custom_fields are enabled per project # thus we need to check the project_custom_field_project_mappings - @available_custom_fields ||= ProjectCustomField + ProjectCustomField .includes(:project_custom_field_section) .where(id: active_custom_field_ids_of_project) end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index ce203305ace6..0d9faf3fb06c 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -27,92 +27,123 @@ #++ require 'spec_helper' - RSpec.describe Project, 'customizable' do - let(:project) do - build_stubbed(:project, - custom_values:) - end - let(:stub_available_custom_fields) do - custom_fields_stub = double('custom fields stub') - allow(CustomField) - .to receive(:where) - .with(type: "ProjectCustomField") - .and_return(custom_fields_stub) - - allow(custom_fields_stub) - .to receive(:order) - .with(:position) - .and_return(available_custom_fields) - end - let(:custom_values) { [] } - let(:bool_custom_field) { build_stubbed(:boolean_project_custom_field) } - let(:text_custom_field) { build_stubbed(:text_project_custom_field) } - let(:list_custom_field) { build_stubbed(:list_project_custom_field) } + let!(:bool_custom_field) { create(:boolean_project_custom_field) } + let!(:text_custom_field) { create(:text_project_custom_field) } + let!(:list_custom_field) { create(:list_project_custom_field) } + + context 'when not persisted' do + let(:project) { build(:project) } - before do - stub_available_custom_fields + describe '#available_custom_fields' do + it 'returns all existing project custom fields as available custom fields' do + expect(project.project_custom_field_project_mappings) + .to be_empty + expect(project.project_custom_fields) + .to be_empty + # but: + expect(project.available_custom_fields) + .to contain_exactly(bool_custom_field, text_custom_field, list_custom_field) + end + end end - describe '#custom_value_for' do - subject { project.custom_value_for(custom_field) } + context 'when persisted' do + shared_let(:project) { create(:project) } + + describe '#active_custom_field_ids_of_project' do + it 'returns all active custom field ids of the project' do + expect(project.active_custom_field_ids_of_project) + .to be_empty + end + end - context 'for a boolean custom field' do - let(:custom_field) { bool_custom_field } - let(:available_custom_fields) { [custom_field] } + describe '#available_custom_fields' do + it 'returns only mapped project custom fields as available custom fields' do + expect(project.project_custom_field_project_mappings) + .to be_empty + expect(project.project_custom_fields) + .to be_empty + # and thus: + expect(project.available_custom_fields) + .to be_empty - context 'with no value set' do - it 'returns a custom value' do - expect(subject) - .to be_present - end + project.project_custom_fields << bool_custom_field - it 'is unpersisted' do - expect(subject) - .to be_new_record - end + expect(project.available_custom_fields) + .to contain_exactly(bool_custom_field) + end + end - it 'has nil as its value' do - expect(subject.value) + describe '#custom_field_values and #custom_value_for' do + context 'when no custom fields are mapped to this project' do + it '#custom_value_for returns nil' do + expect(project.custom_value_for(text_custom_field)) + .to be_nil + expect(project.custom_value_for(bool_custom_field)) .to be_nil + expect(project.custom_value_for(list_custom_field)) + .to be_nil + end + + it '#custom_field_values returns an empty hash' do + expect(project.custom_field_values) + .to be_empty end end - context 'with a value set' do - let(:custom_value) do - build_stubbed(:custom_value, - custom_field:, - value: true) + context 'when custom fields are mapped to this project' do + before do + project.project_custom_fields << [text_custom_field, bool_custom_field] + project.reload # TODO: why is this necessary? end - let(:custom_values) { [custom_value] } - it 'returns the custom value' do - expect(subject) - .to eql custom_value + it '#custom_field_values returns a hash of mapped custom fields with nil values' do + text_custom_field_custom_field_value = project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == text_custom_field.id + end + + expect(text_custom_field_custom_field_value).to be_present + expect(text_custom_field_custom_field_value.value).to be_nil + + bool_custom_field_custom_field_value = project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == bool_custom_field.id + end + + expect(bool_custom_field_custom_field_value).to be_present + expect(bool_custom_field_custom_field_value.value).to be_nil end - end - end - end - describe '#custom_value_attributes' do - let(:available_custom_fields) { [bool_custom_field, list_custom_field, text_custom_field] } - let(:text_custom_value) do - build_stubbed(:custom_value, - custom_field: text_custom_field, - value: 'blubs') - end - let(:bool_custom_value) do - build_stubbed(:custom_value, - custom_field: bool_custom_field, - value: true) - end - let(:custom_values) { [bool_custom_value, text_custom_value] } + context 'when values are set for mapped custom fields' do + before do + project.custom_field_values = { + text_custom_field.id => 'foo', + bool_custom_field.id => true + } + end - subject { project.custom_value_attributes } + it '#custom_value_for returns the set custom values' do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq('foo') + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + expect(project.custom_value_for(list_custom_field)) + .to be_nil + end - it 'returns a hash with all the custom values available' do - expect(subject) - .to eql(text_custom_field.id => 'blubs', bool_custom_field.id => 't', list_custom_field.id => nil) + it '#custom_field_values returns a hash of mapped custom fields with their set values' do + expect(project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == text_custom_field.id + end.typed_value) + .to eq('foo') + + expect(project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == bool_custom_field.id + end.typed_value) + .to be_truthy + end + end + end end end end From efb30cd1466df959f6733dd07baf8d34f977edc4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 13:12:00 +0700 Subject: [PATCH 098/218] added more project model customizable specs --- app/contracts/projects/base_contract.rb | 4 +- .../projects/acts_as_customizable_patches.rb | 28 ++++- .../overviews/overviews_controller.rb | 2 +- spec/models/projects/customizable_spec.rb | 100 +++++++++++++++++- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb index 49e55622adc8..7b0ec44ca038 100644 --- a/app/contracts/projects/base_contract.rb +++ b/app/contracts/projects/base_contract.rb @@ -50,8 +50,8 @@ class BaseContract < ::ModelContract validate_templated_set_by_admin end - attribute :limit_custom_fields_validation_to_section_id - # `limit_custom_fields_validation_to_section_id` used in Projects::ActsAsCustomizablePatches in order to + attribute :_limit_custom_fields_validation_to_section_id + # `_limit_custom_fields_validation_to_section_id` used in Projects::ActsAsCustomizablePatches in order to # only validate custom fields of the touched section validate :validate_user_allowed_to_manage diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 5d5cdbc9e03d..ae58a3aa8a3c 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -29,9 +29,9 @@ module Projects::ActsAsCustomizablePatches extend ActiveSupport::Concern - attr_accessor :limit_custom_fields_validation_to_section_id + attr_accessor :_limit_custom_fields_validation_to_section_id - # attr_accessor :limit_custom_fields_validation_to_field_id + # attr_accessor :_limit_custom_fields_validation_to_field_id # not needed for now, but might be relevant if we want to have edit dialogs just for one custom field included do @@ -40,6 +40,8 @@ module Projects::ActsAsCustomizablePatches has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' before_save :build_missing_project_custom_field_project_mappings + after_save :reset_section_scope + before_create :reject_section_scoped_validation_for_creation after_create :disable_custom_fields_with_empty_values def build_missing_project_custom_field_project_mappings @@ -55,6 +57,19 @@ def build_missing_project_custom_field_project_mappings project_custom_field_project_mappings.build(mappings) end + def reset_section_scope + # reset the section scope after saving + # in order not to silently carry this setting in this instance + self._limit_custom_fields_validation_to_section_id = nil + end + + def reject_section_scoped_validation_for_creation + if _limit_custom_fields_validation_to_section_id.present? + raise ArgumentError, + 'Section scoped validation is not supported for project creation, only for project updates' + end + end + def disable_custom_fields_with_empty_values # run only on initial creation! (otherwise we would deactivate custom fields with empty values on every update!) # @@ -77,6 +92,11 @@ def active_custom_field_ids_of_project ProjectCustomField.pluck(:id) else project_custom_field_project_mappings.pluck(:custom_field_id) + .concat(ProjectCustomField.required.pluck(:id)) + .uniq + # if for whatever reason a required custom field is not activated for this instance, + # we need to make sure it's treated as activated especially in context of the validation + # relevant when a project is created with a section scoped end end @@ -134,8 +154,8 @@ def validate_custom_values end def of_specified_custom_field_section?(custom_value) - if limit_custom_fields_validation_to_section_id.present? - custom_field_section_ids[custom_value.custom_field_id] == limit_custom_fields_validation_to_section_id + if _limit_custom_fields_validation_to_section_id.present? + custom_field_section_ids[custom_value.custom_field_id] == _limit_custom_fields_validation_to_section_id else true # validate all custom values if no specific section was specified end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index a4626350a2ff..088790c59938 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -31,7 +31,7 @@ def update_project_custom_values ) .call( permitted_params.project.merge( - limit_custom_fields_validation_to_section_id: section.id + _limit_custom_fields_validation_to_section_id: section.id ) ) diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 0d9faf3fb06c..24b469db43d2 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -28,9 +28,17 @@ require 'spec_helper' RSpec.describe Project, 'customizable' do - let!(:bool_custom_field) { create(:boolean_project_custom_field) } - let!(:text_custom_field) { create(:text_project_custom_field) } - let!(:list_custom_field) { create(:list_project_custom_field) } + let!(:section) { create(:project_custom_field_section) } + + let!(:bool_custom_field) do + create(:boolean_project_custom_field, project_custom_field_section: section) + end + let!(:text_custom_field) do + create(:text_project_custom_field, project_custom_field_section: section) + end + let!(:list_custom_field) do + create(:list_project_custom_field, project_custom_field_section: section) + end context 'when not persisted' do let(:project) { build(:project) } @@ -146,4 +154,90 @@ end end end + + context 'when creating with custom field values' do + let(:project) do + create(:project, custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + end + + it 'saves the custom field values properly' do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq('foo') + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + end + + it 'enables fields with provided values and disables fields with none' do + # list_custom_field is not provided, thus it should not be enabled + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + expect(project.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + end + + context 'with correct validation' do + let(:another_section) { create(:project_custom_field_section) } + + let!(:required_text_custom_field) do + create(:text_project_custom_field, + is_required: true, + project_custom_field_section: another_section) + end + + it 'validates all custom values if not scoped to a section' do + project = build(:project, custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + + expect(project).not_to be_valid + + expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'rejects section validation scoping for project creation' do + project = build(:project, custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }, + _limit_custom_fields_validation_to_section_id: section.id) + + expect { project.save! }.to raise_error(ArgumentError) + end + + it 'temporarly validates only custom values of a section if section scope is provided while updating' do + project = create(:project, custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true, + required_text_custom_field.id => 'bar' + }) + + expect(project).to be_valid + + # after a project is created, a new require custom field is added + # which gets automatically activated for all projects + create(:text_project_custom_field, + is_required: true, + project_custom_field_section: another_section) + + # thus, the project is invalid in total + expect(project.reload).not_to be_valid + expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) + + # but we still want to allow updating other sections without invalid required custom field values + # by limiting the validation scope to a section temporarily + project._limit_custom_fields_validation_to_section_id = section.id + + expect(project).to be_valid + + expect { project.save! }.not_to raise_error + + # section scope is resetted after each update + expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + end + end end From 3902dfbae0bec410c2454405ac089e0ef67c1a75 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 13:14:46 +0700 Subject: [PATCH 099/218] fixed naming --- app/models/projects/acts_as_customizable_patches.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index ae58a3aa8a3c..b95a2f0bebfa 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -40,7 +40,7 @@ module Projects::ActsAsCustomizablePatches has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' before_save :build_missing_project_custom_field_project_mappings - after_save :reset_section_scope + after_save :reset_section_scoped_validation before_create :reject_section_scoped_validation_for_creation after_create :disable_custom_fields_with_empty_values @@ -57,7 +57,7 @@ def build_missing_project_custom_field_project_mappings project_custom_field_project_mappings.build(mappings) end - def reset_section_scope + def reset_section_scoped_validation # reset the section scope after saving # in order not to silently carry this setting in this instance self._limit_custom_fields_validation_to_section_id = nil From 0f578f8b5cf9ce1a0120efe7808a78be78c40d4a Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 14:30:53 +0700 Subject: [PATCH 100/218] fixed and enriched project csv export specs --- .../projects/exporter/csv_integration_spec.rb | 20 ++++++++-- .../exporter/exportable_project_context.rb | 37 +++++++++++-------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/spec/models/projects/exporter/csv_integration_spec.rb b/spec/models/projects/exporter/csv_integration_spec.rb index 8f72518e42ba..d8a03f2d4a7b 100644 --- a/spec/models/projects/exporter/csv_integration_spec.rb +++ b/spec/models/projects/exporter/csv_integration_spec.rb @@ -62,22 +62,31 @@ describe 'custom field columns selected' do before do - Setting.enabled_projects_columns += custom_fields.map(&:column_name) + Setting.enabled_projects_columns += global_project_custom_fields.map(&:column_name) end context 'when ee enabled', with_ee: %i[custom_fields_in_projects_list] do - it 'renders all those columns' do + it 'renders all globally available project custom fields in the header' do expect(parsed.size).to eq 2 - cf_names = custom_fields.map(&:name) + cf_names = global_project_custom_fields.map(&:name) + + expect(cf_names).to include(not_used_string_cf.name) + expect(header).to eq ['id', 'Identifier', 'Name', 'Description', 'Status', 'Public', *cf_names] + end - custom_values = custom_fields.map do |cf| + it 'renders the custom field values in the rows if enabled for a project' do + expect(parsed.size).to eq 2 + + custom_values = global_project_custom_fields.map do |cf| case cf when bool_cf 'true' when text_cf project.typed_custom_value_for(cf) + when not_used_string_cf + '' else project.formatted_custom_value_for(cf) end @@ -85,6 +94,9 @@ expect(rows.first) .to eq [project.id.to_s, project.identifier, project.name, project.description, 'Off track', 'false', *custom_values] + + # The column for the project-level-disabled custom field is blank + expect(rows.first[header.index(not_used_string_cf.name)]).to eq '' end end diff --git a/spec/models/projects/exporter/exportable_project_context.rb b/spec/models/projects/exporter/exportable_project_context.rb index 25c97c615e45..b62b5b24c180 100644 --- a/spec/models/projects/exporter/exportable_project_context.rb +++ b/spec/models/projects/exporter/exportable_project_context.rb @@ -36,6 +36,8 @@ shared_let(:string_cf) { create(:string_project_custom_field, position: 7) } shared_let(:date_cf) { create(:date_project_custom_field, position: 8) } + let!(:not_used_string_cf) { create(:string_project_custom_field, position: 9) } + shared_let(:system_version) { create(:version, sharing: 'system') } shared_let(:role) do @@ -49,22 +51,24 @@ end shared_let(:project) do - create(:project, - status_code: 'off_track', - status_explanation: 'some explanation', - members: { other_user => role }).tap do |p| - p.description = "The description of the project" - p.send(int_cf.attribute_setter, 5) - p.send(bool_cf.attribute_setter, true) - p.send(version_cf.attribute_setter, system_version) - p.send(float_cf.attribute_setter, 4.5) - p.send(text_cf.attribute_setter, 'Some **long** text') - p.send(string_cf.attribute_setter, 'Some small text') - p.send(date_cf.attribute_setter, Time.zone.today) - p.send(user_cf.attribute_setter, other_user) + project = build(:project, + status_code: 'off_track', + status_explanation: 'some explanation', + members: { other_user => role }, + description: "The description of the project", + custom_field_values: { + int_cf.id => 5, + bool_cf.id => true, + version_cf.id => system_version, + float_cf.id => 4.5, + text_cf.id => 'Some **long** text', + string_cf.id => 'Some small text', + date_cf.id => Time.zone.today, + user_cf.id => other_user.id + }) + project.save!(validate: false) - p.save!(validate: false) - end + project end end @@ -82,7 +86,8 @@ described_class.new(query) end - let(:custom_fields) { project.available_custom_fields } + let(:global_project_custom_fields) { ProjectCustomField.all } + let(:custom_fields_of_project) { project.available_custom_fields } let(:output) do instance.export!.content From 190e6250368f3d3f1a9e5762f1abf409106c64b8 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 14:53:35 +0700 Subject: [PATCH 101/218] fixed existing project custom field settings page specs --- .../projects/projects_custom_fields_spec.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/features/projects/projects_custom_fields_spec.rb b/spec/features/projects/projects_custom_fields_spec.rb index 856274324bd5..60aba1edbcd2 100644 --- a/spec/features/projects/projects_custom_fields_spec.rb +++ b/spec/features/projects/projects_custom_fields_spec.rb @@ -96,7 +96,10 @@ default_int_field.expect_value default_int_custom_field.default_value.to_s default_string_field.expect_value 'Overwritten' - no_default_string_field.expect_value '' + + # The custom field without default value should not be shown + # as it didn't got activated with a value during project creation + expect(page).to have_no_css("[data-qa-field-name='customField#{no_default_string_custom_field.id}']") end end @@ -106,6 +109,11 @@ end let(:editor) { Components::WysiwygEditor.new "[data-qa-field-name='customField#{custom_field.id}']" } + before do + # enable the custom field for the project + project.project_custom_fields << custom_field + end + it 'allows settings the project boolean CF (regression #26313)' do visit project_settings_general_path(project.id) @@ -196,6 +204,11 @@ create(:boolean_project_custom_field) end + before do + # enable the custom field for the project + project.project_custom_fields << custom_field + end + it 'allows settings the project boolean CF (regression #26313)' do visit project_settings_general_path(project.id) field = page.find(identifier) @@ -256,6 +269,8 @@ end it 'allows inviting a new user immediately (regression #39166)' do + project.project_custom_fields << custom_field # enable the custom field for the project + visit project_settings_general_path(project.id) cf_field.expect_visible From e9362507f87a1f19b3d0f079611372ceb411d3a4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 18:00:58 +0700 Subject: [PATCH 102/218] support easy usage of update method with implicit custom field activation --- .../projects/acts_as_customizable_patches.rb | 44 +++++++++++++++++-- spec/models/projects/customizable_spec.rb | 43 ++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index b95a2f0bebfa..d65e112170bd 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -29,7 +29,7 @@ module Projects::ActsAsCustomizablePatches extend ActiveSupport::Concern - attr_accessor :_limit_custom_fields_validation_to_section_id + attr_accessor :_limit_custom_fields_validation_to_section_id, :_query_available_custom_fields_on_global_level # attr_accessor :_limit_custom_fields_validation_to_field_id # not needed for now, but might be relevant if we want to have edit dialogs just for one custom field @@ -104,9 +104,14 @@ def available_custom_fields # overrides acts_as_customizable # in contrast to acts_as_customizable, custom_fields are enabled per project # thus we need to check the project_custom_field_project_mappings - ProjectCustomField + custom_fields = ProjectCustomField .includes(:project_custom_field_section) - .where(id: active_custom_field_ids_of_project) + + unless _query_available_custom_fields_on_global_level + custom_fields = custom_fields.where(id: active_custom_field_ids_of_project) + end + + custom_fields end def available_project_custom_fields_grouped_by_section @@ -160,5 +165,38 @@ def of_specified_custom_field_section?(custom_value) true # validate all custom values if no specific section was specified end end + + # patching the update methods directly as rails before/after/around update hooks did not work in this context + # + # reason for the patch: + # we need to query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + # _query_available_custom_fields_on_global_level is used within the patched `available_custom_fields` method + # + # TODO: this patch seems far from ideal, find better solution for this + # + def update(attributes) + if attributes[:custom_field_values].present? + self._query_available_custom_fields_on_global_level = true + result = super(attributes) + self._query_available_custom_fields_on_global_level = false + + result + else + super + end + end + + def update!(attributes) + if attributes[:custom_field_values].present? + self._query_available_custom_fields_on_global_level = true + result = super(attributes) + self._query_available_custom_fields_on_global_level = false + + result + else + super + end + end end end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 24b469db43d2..afc3b7da7f81 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -240,4 +240,47 @@ end end end + + context 'when updating with custom field values' do + let(:project) { create(:project) } + + shared_examples 'implicitly enabled and saved custom values' do + it 'enables fields with provided values' do + # list_custom_field is not provided, thus it should not be enabled + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + expect(project.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + end + + it 'saves the custom field values properly' do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq('foo') + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + end + end + + context 'with #update method' do + before do + project.update(custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + end + + it_behaves_like 'implicitly enabled and saved custom values' + end + + context 'with #update! method' do + before do + project.update!(custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + end + + it_behaves_like 'implicitly enabled and saved custom values' + end + end end From 96f112e8cf06e54612b8346303d4035ebd2779f2 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 18:33:54 +0700 Subject: [PATCH 103/218] support implicit activation of custom fields when using custom_field_values method --- .../projects/acts_as_customizable_patches.rb | 38 ++++++++++++++++++- spec/models/projects/customizable_spec.rb | 15 +++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index d65e112170bd..1ca6560c4d79 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -40,8 +40,11 @@ module Projects::ActsAsCustomizablePatches has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' before_save :build_missing_project_custom_field_project_mappings - after_save :reset_section_scoped_validation + + after_save :reset_section_scoped_validation, :reset_query_available_custom_fields_on_global_level + before_create :reject_section_scoped_validation_for_creation + after_create :disable_custom_fields_with_empty_values def build_missing_project_custom_field_project_mappings @@ -63,6 +66,12 @@ def reset_section_scoped_validation self._limit_custom_fields_validation_to_section_id = nil end + def reset_query_available_custom_fields_on_global_level + # reset the query_available_custom_fields_on_global_level after saving + # in order not to silently carry this setting in this instance + self._query_available_custom_fields_on_global_level = nil + end + def reject_section_scoped_validation_for_creation if _limit_custom_fields_validation_to_section_id.present? raise ArgumentError, @@ -198,5 +207,32 @@ def update!(attributes) super end end + + def custom_field_values=(values) + # overrides acts_as_customizable + # we need to query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + self._query_available_custom_fields_on_global_level = true + set_custom_field_values_method_from_acts_as_customizable_module(values) + end + + # we cannot call super as the code in acts_as_customizable is shipped in a module + # thus copy and pasted the code from acts_as_customizable here + def set_custom_field_values_method_from_acts_as_customizable_module(values) + return unless values.is_a?(Hash) && values.any? + + values.with_indifferent_access.each do |custom_field_id, val| + existing_cv_by_value = custom_values_for_custom_field(id: custom_field_id) + .group_by(&:value) + .transform_values(&:first) + new_values = Array(val).map { |v| v.respond_to?(:id) ? v.id.to_s : v.to_s } + + if existing_cv_by_value.any? + assign_new_values custom_field_id, existing_cv_by_value, new_values + delete_obsolete_custom_values existing_cv_by_value, new_values + handle_minimum_custom_value custom_field_id, existing_cv_by_value, new_values + end + end + end end end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index afc3b7da7f81..605ac6006fef 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -135,7 +135,7 @@ .to eq('foo') expect(project.custom_value_for(bool_custom_field).typed_value) .to be_truthy - expect(project.custom_value_for(list_custom_field)) + expect(project.custom_value_for(list_custom_field).typed_value) .to be_nil end @@ -282,5 +282,18 @@ it_behaves_like 'implicitly enabled and saved custom values' end + + context 'with #custom_field_values= method' do + before do + project.custom_field_values = { + text_custom_field.id => 'foo', + bool_custom_field.id => true + } + + project.save! + end + + it_behaves_like 'implicitly enabled and saved custom values' + end end end From da8be06882832bb3e86db694bf7a4902b36ad529 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 26 Feb 2024 18:34:16 +0700 Subject: [PATCH 104/218] fixed project index specs --- app/components/projects/row_component.rb | 2 +- spec/features/projects/projects_index_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 487c1a8c9ac7..7dc738e16eef 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -58,7 +58,7 @@ def custom_field_column(column) custom_value = project.formatted_custom_value_for(cf) if cf.field_format == 'text' - custom_value.html_safe # rubocop:disable Rails/OutputSafety + custom_value&.html_safe # rubocop:disable Rails/OutputSafety elsif custom_value.is_a?(Array) safe_join(Array(custom_value).compact_blank, ', ') else diff --git a/spec/features/projects/projects_index_spec.rb b/spec/features/projects/projects_index_spec.rb index 0009b06932d8..b2f27c2cccb4 100644 --- a/spec/features/projects/projects_index_spec.rb +++ b/spec/features/projects/projects_index_spec.rb @@ -316,7 +316,7 @@ def expect_projects_in_order(*projects) # The same filters should still be intact but the order should be DESC on name projects_page.expect_projects_listed(public_project) - projects_page.expect_projects_not_listed(project, # Filtered out + projects_page.expect_projects_not_listed(project, # Filtered out development_project) # Present on page 2 projects_page.expect_total_pages(2) # Filters kept active, so there is no third page. From 5071959c29965abfb557a70e4ed9427d37aa46d0 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 27 Feb 2024 12:16:12 +0700 Subject: [PATCH 105/218] fixing custom field scope after validation --- .../projects/acts_as_customizable_patches.rb | 55 ++++++------------- spec/models/projects/customizable_spec.rb | 4 +- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 1ca6560c4d79..ccec9b0548ba 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -40,11 +40,16 @@ module Projects::ActsAsCustomizablePatches has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' before_save :build_missing_project_custom_field_project_mappings - after_save :reset_section_scoped_validation, :reset_query_available_custom_fields_on_global_level - before_create :reject_section_scoped_validation_for_creation + # we need to reset the query_available_custom_fields_on_global_level already after validation + # as the update service just calls .valid? and returns if invalid + # after_save is not touched in this case which causes the flag to stay active + after_validation :reset_query_available_custom_fields_on_global_level + + before_update :query_available_custom_fields_on_global_level + before_create :reject_section_scoped_validation_for_creation after_create :disable_custom_fields_with_empty_values def build_missing_project_custom_field_project_mappings @@ -66,6 +71,12 @@ def reset_section_scoped_validation self._limit_custom_fields_validation_to_section_id = nil end + def query_available_custom_fields_on_global_level + # query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + self._query_available_custom_fields_on_global_level = true + end + def reset_query_available_custom_fields_on_global_level # reset the query_available_custom_fields_on_global_level after saving # in order not to silently carry this setting in this instance @@ -105,7 +116,6 @@ def active_custom_field_ids_of_project .uniq # if for whatever reason a required custom field is not activated for this instance, # we need to make sure it's treated as activated especially in context of the validation - # relevant when a project is created with a section scoped end end @@ -116,6 +126,10 @@ def available_custom_fields custom_fields = ProjectCustomField .includes(:project_custom_field_section) + # available_custom_fields is called from within the acts_as_customizable module + # we don't want to adjust these calls, but need a way to query the available custom fields on a global level in some cases + # thus we pass in this parameter as an instance flag implicitly here, + # which is not nice but helps us to touch acts_as_customizable as little as possible unless _query_available_custom_fields_on_global_level custom_fields = custom_fields.where(id: active_custom_field_ids_of_project) end @@ -175,44 +189,11 @@ def of_specified_custom_field_section?(custom_value) end end - # patching the update methods directly as rails before/after/around update hooks did not work in this context - # - # reason for the patch: - # we need to query the available custom fields on a global level when updating custom field values - # in order to support implicit activation of custom fields when values are provided during an update - # _query_available_custom_fields_on_global_level is used within the patched `available_custom_fields` method - # - # TODO: this patch seems far from ideal, find better solution for this - # - def update(attributes) - if attributes[:custom_field_values].present? - self._query_available_custom_fields_on_global_level = true - result = super(attributes) - self._query_available_custom_fields_on_global_level = false - - result - else - super - end - end - - def update!(attributes) - if attributes[:custom_field_values].present? - self._query_available_custom_fields_on_global_level = true - result = super(attributes) - self._query_available_custom_fields_on_global_level = false - - result - else - super - end - end - def custom_field_values=(values) # overrides acts_as_customizable # we need to query the available custom fields on a global level when updating custom field values # in order to support implicit activation of custom fields when values are provided during an update - self._query_available_custom_fields_on_global_level = true + self._query_available_custom_fields_on_global_level = true # set to false in after_save hook set_custom_field_values_method_from_acts_as_customizable_module(values) end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 605ac6006fef..6ddbdde236c8 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -57,7 +57,7 @@ end context 'when persisted' do - shared_let(:project) { create(:project) } + let(:project) { create(:project) } describe '#active_custom_field_ids_of_project' do it 'returns all active custom field ids of the project' do @@ -217,7 +217,7 @@ expect(project).to be_valid - # after a project is created, a new require custom field is added + # after a project is created, a new required custom field is added # which gets automatically activated for all projects create(:text_project_custom_field, is_required: true, From 1bfadbc4893c311e50106c0780c886b53632b829 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 27 Feb 2024 14:12:01 +0700 Subject: [PATCH 106/218] addings spec for after validation state of dialog --- .../overview_page/dialog/validation_spec.rb | 45 +++++++++++++++++++ .../overview_page/shared_context.rb | 5 +++ 2 files changed, 50 insertions(+) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb index 3d8081008f5b..c4d20d686bbf 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -40,6 +40,51 @@ end describe 'with correct validation behaviour' do + describe 'after validation' do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + it 'keeps showing only activated custom fields (tricky regression)' do + custom_field = string_project_custom_field + custom_field.update!(is_required: true) + field = FormFields::Primerized::InputField.new(custom_field) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content do + containers = dialog.input_containers + + expect(containers[0].text).to include('Boolean field') + expect(containers[1].text).to include('String field') + expect(containers[2].text).to include('Integer field') + expect(containers[3].text).to include('Float field') + expect(containers[4].text).to include('Date field') + expect(containers[5].text).to include('Text field') + + expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) + end + + field.fill_in(with: '') # this will trigger the validation + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + dialog.within_async_content do + containers = dialog.input_containers + + expect(containers[0].text).to include('Boolean field') + expect(containers[1].text).to include('String field') + expect(containers[2].text).to include('Integer field') + expect(containers[3].text).to include('Float field') + expect(containers[4].text).to include('Date field') + expect(containers[5].text).to include('Text field') + + expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) + end + end + end + describe 'with input fields' do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb index 3c50c90a4524..5cfb258b2434 100644 --- a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -224,4 +224,9 @@ multi_user_project_custom_field ] end + + let!(:boolean_project_custom_field_activated_in_other_project) do + create(:boolean_project_custom_field, projects: [other_project], name: 'Other Boolean field', + project_custom_field_section: section_for_input_fields) + end end From 5c5aae175d11c049482106dbbedbca52b0a38864 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 27 Feb 2024 14:27:39 +0700 Subject: [PATCH 107/218] added missing validation specs --- .../overview_page/dialog/validation_spec.rb | 78 +++++++++++++++++++ .../primerized/autocomplete_field.rb | 5 ++ 2 files changed, 83 insertions(+) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb index c4d20d686bbf..55726a535392 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -247,5 +247,83 @@ end end end + + describe 'with select fields' do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field select' do + it 'shows an error if the value is invalid' do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.blank')) + end + end + + describe 'with list CF' do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like 'a custom field select' + end + + describe 'with version CF' do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like 'a custom field select' + end + + describe 'with user CF' do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like 'a custom field select' + end + end + + describe 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples 'a custom field multi select' do + it 'shows an error if the value is invalid' do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.blank')) + end + end + + describe 'with multi list CF' do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like 'a custom field multi select' + end + + describe 'with multi version CF' do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like 'a custom field multi select' + end + + describe 'with multi user CF' do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like 'a custom field multi select' + end + end end end diff --git a/spec/support/form_fields/primerized/autocomplete_field.rb b/spec/support/form_fields/primerized/autocomplete_field.rb index 4873ebf5b117..fb1d325310d2 100644 --- a/spec/support/form_fields/primerized/autocomplete_field.rb +++ b/spec/support/form_fields/primerized/autocomplete_field.rb @@ -63,6 +63,11 @@ def expect_option(option) def expect_visible expect(field_container).to have_css('ng-select') end + + def expect_error(string = nil) + expect(field_container).to have_css('.FormControl-inlineValidation') + expect(field_container).to have_content(string) if string + end end end end From 34a4640dc40a4caf62fb6bee2a090a8bd7571da2 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 27 Feb 2024 15:19:35 +0700 Subject: [PATCH 108/218] adding more validation specs and fixing selected values state after validation for multi select fields --- .../custom_fields/inputs/multi_select_list.rb | 6 +- .../overview_page/dialog/validation_spec.rb | 169 ++++++++++++++++++ .../form_fields/primerized/input_field.rb | 5 + 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/app/forms/custom_fields/inputs/multi_select_list.rb b/app/forms/custom_fields/inputs/multi_select_list.rb index 152ce7b0ef35..f11f8cf5a396 100644 --- a/app/forms/custom_fields/inputs/multi_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_select_list.rb @@ -54,10 +54,8 @@ def decorated? end def selected?(custom_option) - cf_values = @custom_values.reject { |custom_value| custom_value.id.nil? } - - if cf_values.any? - cf_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) + if @custom_values.any? + @custom_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) else custom_option.default_value? end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb index 55726a535392..ddbca5cc622e 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -83,6 +83,175 @@ expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) end end + + describe 'does not loose the unpersisted values of the custom fields' do + context 'with input fields' do + let(:section) { section_for_input_fields } + + let(:invalid_custom_field) { string_project_custom_field } + let(:valid_custom_field) { integer_project_custom_field } + let(:invalid_field) { FormFields::Primerized::InputField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::InputField.new(valid_custom_field) } + + it 'keeps the value' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.fill_in(with: '') + valid_field.fill_in(with: '123') + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_value('') + valid_field.expect_value('123') + end + end + + context 'with select fields' do + let(:section) { section_for_select_fields } + + context 'with version selected' do + let(:invalid_custom_field) { list_project_custom_field } + let(:valid_custom_field) { version_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it 'keeps the value' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.select_option(third_version.name) + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_blank + valid_field.expect_selected(third_version.name) + end + end + + context 'with user selected' do + let(:invalid_custom_field) { list_project_custom_field } + let(:valid_custom_field) { user_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it 'keeps the value' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.select_option(another_member_in_project.name) + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_blank + valid_field.expect_selected(another_member_in_project.name) + end + end + + context 'with list selected' do + let(:invalid_custom_field) { user_project_custom_field } + let(:valid_custom_field) { list_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it 'keeps the value' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.select_option('Option 3') + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_blank + valid_field.expect_selected('Option 3') + end + end + end + + context 'with multi select fields' do + let(:section) { section_for_multi_select_fields } + + context 'with multi version selected' do + let(:invalid_custom_field) { multi_list_project_custom_field } + let(:valid_custom_field) { multi_version_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it 'keeps the values' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.clear + valid_field.select_option(first_version.name, third_version.name) + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_blank + valid_field.expect_selected(first_version.name, third_version.name) + end + end + + context 'with multi user selected' do + let(:invalid_custom_field) { multi_list_project_custom_field } + let(:valid_custom_field) { multi_user_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it 'keeps the values' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.clear + valid_field.select_option(member_in_project.name, one_more_member_in_project.name) + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_blank + valid_field.expect_selected(member_in_project.name, one_more_member_in_project.name) + end + end + + context 'with multi list selected' do + let(:invalid_custom_field) { multi_user_project_custom_field } + let(:valid_custom_field) { multi_list_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it 'keeps the value' do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.clear + valid_field.select_option('Option 1', 'Option 3') + + dialog.submit + + invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + + invalid_field.expect_blank + valid_field.expect_selected('Option 1', 'Option 3') + end + end + end + end end describe 'with input fields' do diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb index 49168b92022c..6b7d87db7dab 100644 --- a/spec/support/form_fields/primerized/input_field.rb +++ b/spec/support/form_fields/primerized/input_field.rb @@ -23,6 +23,11 @@ def expect_error(string = nil) expect(page).to have_css("#{selector}[invalid='true']") expect(field_container).to have_content(string) if string end + + def expect_value(value) + scroll_to_element(field_container) + expect(field_container).to have_css('input') { |el| el.value == value } + end end end end From 8610e769f0e42032226cc6b834396d88a046533a Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 28 Feb 2024 15:31:11 +0700 Subject: [PATCH 109/218] fixing project API --- app/contracts/base_contract.rb | 17 +++++++- app/models/custom_value.rb | 10 +++++ .../projects/acts_as_customizable_patches.rb | 42 +++++++++++++++---- spec/models/custom_value_spec.rb | 12 ++++++ spec/models/projects/customizable_spec.rb | 36 ++++++++++++++++ .../api/v3/projects/create_resource_spec.rb | 5 +++ .../api/v3/projects/update_resource_spec.rb | 5 +++ 7 files changed, 118 insertions(+), 9 deletions(-) diff --git a/app/contracts/base_contract.rb b/app/contracts/base_contract.rb index c57c66d1a60d..9402f3131818 100644 --- a/app/contracts/base_contract.rb +++ b/app/contracts/base_contract.rb @@ -222,12 +222,27 @@ def collect_writable_attributes end if model.respond_to?(:available_custom_fields) - writable += model.available_custom_fields.map(&:attribute_name) + writable += collect_available_custom_field_attributes end writable end + def collect_available_custom_field_attributes + if model.is_a?(Project) + # required because project custom fields are now activated on a per-project basis + # + # if we wouldn't query available_custom field on a global level here, + # implicitly enabling project custom fields through this contract would fail + # as the disabled custom fields would be treated as not-writable + # + # relevant especially for the project API + model.available_custom_fields(global: true).map(&:attribute_name) + else + model.available_custom_fields.map(&:attribute_name) + end + end + def reduce_writable_attributes(attributes) attributes = reduce_by_writable_conditions(attributes) reduce_by_writable_permissions(attributes) diff --git a/app/models/custom_value.rb b/app/models/custom_value.rb index e569001ec92a..eeee77edd8b2 100644 --- a/app/models/custom_value.rb +++ b/app/models/custom_value.rb @@ -35,6 +35,8 @@ class CustomValue < ApplicationRecord validate :validate_type_of_value validate :validate_length_of_value + after_create :activate_custom_field_in_customized_project, if: -> { customized.is_a?(Project) && value.present? } + delegate :typed_value, :formatted_value, to: :strategy @@ -66,6 +68,14 @@ def default? || value_is_same_as_default? end + def activate_custom_field_in_customized_project + # if a custom value is created for a project via CustomValue.create(...), + # the custom field needs to be activated in the project + unless customized&.project_custom_fields&.include?(custom_field) + customized.project_custom_fields << custom_field + end + end + protected def value_is_included_in_multi_value_default? diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index ccec9b0548ba..0807d96779e3 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -40,14 +40,14 @@ module Projects::ActsAsCustomizablePatches has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' before_save :build_missing_project_custom_field_project_mappings - after_save :reset_section_scoped_validation, :reset_query_available_custom_fields_on_global_level + after_save :reset_section_scoped_validation, :set_query_available_custom_fields_to_project_level # we need to reset the query_available_custom_fields_on_global_level already after validation # as the update service just calls .valid? and returns if invalid # after_save is not touched in this case which causes the flag to stay active - after_validation :reset_query_available_custom_fields_on_global_level + after_validation :set_query_available_custom_fields_to_project_level - before_update :query_available_custom_fields_on_global_level + before_update :set_query_available_custom_fields_to_global_level before_create :reject_section_scoped_validation_for_creation after_create :disable_custom_fields_with_empty_values @@ -59,7 +59,7 @@ def build_missing_project_custom_field_project_mappings custom_field_ids = project.custom_values.reject { |cv| cv.value.blank? }.pluck(:custom_field_id).uniq activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id).uniq - mappings = (custom_field_ids - activated_custom_field_ids) + mappings = (custom_field_ids - activated_custom_field_ids).uniq .map { |pcf_id| { project_id: id, custom_field_id: pcf_id } } project_custom_field_project_mappings.build(mappings) @@ -71,13 +71,13 @@ def reset_section_scoped_validation self._limit_custom_fields_validation_to_section_id = nil end - def query_available_custom_fields_on_global_level + def set_query_available_custom_fields_to_global_level # query the available custom fields on a global level when updating custom field values # in order to support implicit activation of custom fields when values are provided during an update self._query_available_custom_fields_on_global_level = true end - def reset_query_available_custom_fields_on_global_level + def set_query_available_custom_fields_to_project_level # reset the query_available_custom_fields_on_global_level after saving # in order not to silently carry this setting in this instance self._query_available_custom_fields_on_global_level = nil @@ -103,6 +103,16 @@ def disable_custom_fields_with_empty_values project_custom_field_project_mappings.where(custom_field_id: custom_field_ids).destroy_all end + def query_available_custom_fields_on_global_level + # query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + self._query_available_custom_fields_on_global_level = true + result = yield + self._query_available_custom_fields_on_global_level = false + + result + end + def active_custom_field_ids_of_project # show all project custom fields in the project creation form # later on, only those with values will be activated via before_save hook `build_missing_project_custom_field_project_mappings` @@ -119,7 +129,7 @@ def active_custom_field_ids_of_project end end - def available_custom_fields + def available_custom_fields(global: false) # overrides acts_as_customizable # in contrast to acts_as_customizable, custom_fields are enabled per project # thus we need to check the project_custom_field_project_mappings @@ -130,7 +140,10 @@ def available_custom_fields # we don't want to adjust these calls, but need a way to query the available custom fields on a global level in some cases # thus we pass in this parameter as an instance flag implicitly here, # which is not nice but helps us to touch acts_as_customizable as little as possible - unless _query_available_custom_fields_on_global_level + # + # additionally we provide the `global` parameter to allow querying the available custom fields on a global level + # when we have explicit control over the call of `available_custom_fields` + unless global || _query_available_custom_fields_on_global_level custom_fields = custom_fields.where(id: active_custom_field_ids_of_project) end @@ -215,5 +228,18 @@ def set_custom_field_values_method_from_acts_as_customizable_module(values) end end end + + # overrides acts_as_customizable + # we need to query the available custom fields on a global level when + # trying to set a custom field which is not enabled via e.g. custom_field_123="foo" + def for_custom_field_accessor(method_symbol) + match = /\Acustom_field_(?\d+)=?\z/.match(method_symbol.to_s) + if match + custom_field = available_custom_fields(global: true).find { |cf| cf.id.to_s == match[:id] } + if custom_field + yield custom_field + end + end + end end end diff --git a/spec/models/custom_value_spec.rb b/spec/models/custom_value_spec.rb index 873e9139b87a..e9476f488ea2 100644 --- a/spec/models/custom_value_spec.rb +++ b/spec/models/custom_value_spec.rb @@ -729,4 +729,16 @@ expect(custom_value.value).to eql parsed_value end end + + describe '#activate_custom_field_in_customized_project' do + let(:custom_field) { create(:project_custom_field) } + let(:project) { create(:project) } + let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).to include(custom_field) + end + end end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 6ddbdde236c8..153a5f3df59d 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -296,4 +296,40 @@ it_behaves_like 'implicitly enabled and saved custom values' end end + + context 'when updating with custom field setter methods (API approach)' do + let(:project) { create(:project) } + + shared_examples 'implicitly enabled and saved custom values' do + it 'enables fields with provided values' do + # list_custom_field is not provided, thus it should not be enabled + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + expect(project.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + end + + it 'saves the custom field values properly' do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq('foo') + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + + # or via getter methods: + + expect(project.send(:"custom_field_#{text_custom_field.id}")).to eq('foo') + expect(project.send(:"custom_field_#{bool_custom_field.id}")).to be_truthy + end + end + + context 'when setting a value for a disabled custom field' do + before do + project.send(:"custom_field_#{text_custom_field.id}=", 'foo') + project.send(:"custom_field_#{bool_custom_field.id}=", true) + project.save! + end + + it_behaves_like 'implicitly enabled and saved custom values' + end + end end diff --git a/spec/requests/api/v3/projects/create_resource_spec.rb b/spec/requests/api/v3/projects/create_resource_spec.rb index 67df535c2ec3..3be223c3fc58 100644 --- a/spec/requests/api/v3/projects/create_resource_spec.rb +++ b/spec/requests/api/v3/projects/create_resource_spec.rb @@ -129,6 +129,11 @@ .to be_json_eql("CF text".to_json) .at_path("customField#{custom_field.id}/raw") end + + it 'automatically activates the cf for project if the value was provided' do + expect(Project.last.project_custom_fields) + .to contain_exactly(custom_field) + end end context 'without permission to create projects' do diff --git a/spec/requests/api/v3/projects/update_resource_spec.rb b/spec/requests/api/v3/projects/update_resource_spec.rb index 23df8bc06406..e4cf940bc814 100644 --- a/spec/requests/api/v3/projects/update_resource_spec.rb +++ b/spec/requests/api/v3/projects/update_resource_spec.rb @@ -106,6 +106,11 @@ expect(project.reload.send(custom_field.attribute_getter)) .to eql("CF text") end + + it 'automatically activates the cf for project if the value was provided' do + expect(project.project_custom_fields) + .to contain_exactly(custom_field) + end end context 'without permission to patch projects' do From 33e40b39eeb1a494b726f12cf08bd374a19a6ff4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 28 Feb 2024 15:47:55 +0700 Subject: [PATCH 110/218] fixed not desired reactivation of custom fields which have been disabled in the past --- .../projects/acts_as_customizable_patches.rb | 5 +- spec/models/projects/customizable_spec.rb | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 0807d96779e3..c544762f485d 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -56,7 +56,10 @@ def build_missing_project_custom_field_project_mappings # activate custom fields for this project (via mapping table) if values have been provided for custom_fields but no mapping exists # current shortcommings: # - boolean custom fields are always activated as a nil value is never provided (always true/false) - custom_field_ids = project.custom_values.reject { |cv| cv.value.blank? }.pluck(:custom_field_id).uniq + custom_field_ids = project.custom_values + .reject { |cv| cv.value.blank? } + .reject { |cv| cv.persisted? } # do not reactivate custom fields which have already been used and disabled + .pluck(:custom_field_id).uniq activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id).uniq mappings = (custom_field_ids - activated_custom_field_ids).uniq diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 153a5f3df59d..6debf0516c04 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -295,6 +295,55 @@ it_behaves_like 'implicitly enabled and saved custom values' end + + it 'does not re-enable fields without new value which have been disabled in the past (regression)' do + project.update!(custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + + project.project_custom_field_project_mappings.find_by(custom_field_id: text_custom_field.id).destroy + + expect(project.reload.project_custom_fields) + .to contain_exactly(bool_custom_field) + + project.update!(custom_field_values: { + bool_custom_field.id => true + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(bool_custom_field) + end + + it 'does re-enable fields with new value which have been disabled in the past' do + pending "this is currently not working, not sure if it should be supported at all" + + project.update!(custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + + project.project_custom_field_project_mappings.find_by(custom_field_id: text_custom_field.id).destroy + + expect(project.reload.project_custom_fields) + .to contain_exactly(bool_custom_field) + + project.update!(custom_field_values: { + text_custom_field.id => 'bar' + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq('bar') + end end context 'when updating with custom field setter methods (API approach)' do From 10bab9616f8a4a1b691a340bca7e39ef19fcd064 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:21:51 +0200 Subject: [PATCH 111/218] Make the project attributes rich text display "Expand" link inline. --- app/models/custom_value/formattable_strategy.rb | 3 +-- config/locales/en.yml | 1 + frontend/src/global_styles/openproject.sass | 1 + modules/overviews/app/components/_index.sass | 1 + .../sections/project_custom_fields/show_component.rb | 9 +++------ .../sections/project_custom_fields/show_component.sass | 2 ++ 6 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 modules/overviews/app/components/_index.sass create mode 100644 modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass diff --git a/app/models/custom_value/formattable_strategy.rb b/app/models/custom_value/formattable_strategy.rb index dd7c19c3a596..884c9346d1dc 100644 --- a/app/models/custom_value/formattable_strategy.rb +++ b/app/models/custom_value/formattable_strategy.rb @@ -26,8 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class - CustomValue::FormattableStrategy < CustomValue::FormatStrategy +class CustomValue::FormattableStrategy < CustomValue::FormatStrategy def formatted_value OpenProject::TextFormatting::Renderer.format_text value end diff --git a/config/locales/en.yml b/config/locales/en.yml index 14d76280037b..663f0c2b869a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1951,6 +1951,7 @@ Project attributes and sections are defined in the Date: Thu, 29 Feb 2024 12:35:59 +0200 Subject: [PATCH 112/218] Format code --- .../sections/project_custom_fields/show_component.sass | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass index dc478209e068..77fb285f9188 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass @@ -1,2 +1,3 @@ -.project-custom-fields-rich-text-preview :last-child - display: inline +.project-custom-fields-rich-text-preview + :last-child + display: inline From 4c9ceb37ad7a4cff47294527caca1006d6fac80f Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 29 Feb 2024 17:23:18 +0200 Subject: [PATCH 113/218] Fix validation messages not being displayed when a previous section was closed without saving. --- .../sections/edit_dialog_component.rb | 4 ++++ .../overview_page/dialog/validation_spec.rb | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb index 29d2f3d68fdc..9e2d79744951 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb @@ -40,6 +40,10 @@ def initialize(project:, @project = project @project_custom_field_section = project_custom_field_section end + + def wrapper_uniq_by + @project_custom_field_section.id + end end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb index ddbca5cc622e..460d7253ddf1 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -254,6 +254,28 @@ end end + describe 'editing multiple sections' do + let(:input_fields_dialog) do + Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) + end + let(:select_fields_dialog) do + Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) + end + let(:field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) } + + it 'displays validation errors, when the previous section modal was canceled (Regression)' do + list_project_custom_field.update!(is_required: true) + list_project_custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + input_fields_dialog.close + overview_page.open_edit_dialog_for_section(section_for_select_fields) + select_fields_dialog.submit + + field.expect_error(I18n.t('activerecord.errors.messages.blank')) + end + end + describe 'with input fields' do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } From a1b612d5d824b8844328fdd8956586b22dfa4436 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 29 Feb 2024 18:56:01 +0200 Subject: [PATCH 114/218] Respect allow_non_open_versions setting on the version input, do not wait on non existence expectations. --- .../inputs/single_version_select_list.rb | 7 ++- .../overview_page/dialog/inputs_spec.rb | 48 ++++++++++++++++--- .../primerized/autocomplete_field.rb | 8 +++- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/app/forms/custom_fields/inputs/single_version_select_list.rb b/app/forms/custom_fields/inputs/single_version_select_list.rb index 18ebebd03ff2..19f832c916d6 100644 --- a/app/forms/custom_fields/inputs/single_version_select_list.rb +++ b/app/forms/custom_fields/inputs/single_version_select_list.rb @@ -27,6 +27,10 @@ #++ class CustomFields::Inputs::SingleVersionSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput + include AssignableCustomFieldValues + + delegate :assignable_versions, to: :@object + form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field @@ -34,8 +38,7 @@ class CustomFields::Inputs::SingleVersionSelectList < CustomFields::Inputs::Base custom_value_form.hidden(**input_attributes.merge(value: "")) custom_value_form.autocompleter(**input_attributes) do |list| - # TODO: allow-non-open version setting is not yet respected! - @object.versions.each do |version| + assignable_custom_field_values(@custom_field).each do |version| list.option( label: version.name, value: version.id, selected: selected?(version) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb index 549b10d15c47..17efbefbeb45 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb @@ -295,17 +295,51 @@ it_behaves_like 'a autocomplete single select field' describe 'with correct version scoping' do - let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) + context 'with a version on a different project' do + let!(:version_in_other_project) do + create(:version, name: 'Version 1 in other project', project: other_project) + end + + it 'shows only versions that are associated with this project' do + overview_page.open_edit_dialog_for_section(section) + + field.search('Version 1') + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end end - it 'shows only versions that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) + context 'with a closed version' do + let!(:closed_version) { create(:version, name: 'Closed version', project:, status: 'closed') } - field.search('Version 1') + before do + custom_field.update(allow_non_open_versions:) + end - field.expect_option(first_version.name) - field.expect_no_option(version_in_other_project.name) + context 'when non-open versions are not allowed' do + let(:allow_non_open_versions) { false } + + it 'does not shows closed version option' do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_no_option(closed_version.name) + end + end + + context 'when non-open versions are allowed' do + let(:allow_non_open_versions) { true } + + it 'shows closed version option' do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_option(closed_version.name) + end + end end end end diff --git a/spec/support/form_fields/primerized/autocomplete_field.rb b/spec/support/form_fields/primerized/autocomplete_field.rb index fb1d325310d2..8e4b0bf37e01 100644 --- a/spec/support/form_fields/primerized/autocomplete_field.rb +++ b/spec/support/form_fields/primerized/autocomplete_field.rb @@ -28,6 +28,10 @@ def search(text) field_container.find('.ng-select-container input').set text end + def open_options + field_container.find('.ng-select-container').click + end + def clear field_container.find('.ng-clear-wrapper', visible: :all).click end @@ -42,7 +46,7 @@ def expect_selected(*values) def expect_not_selected(*values) values.each do |val| - expect(field_container).to have_no_css('.ng-value', text: val) + expect(field_container).to have_no_css('.ng-value', text: val, wait: 1) end end @@ -52,7 +56,7 @@ def expect_blank def expect_no_option(option) expect(page) - .to have_no_css('.ng-option', text: option, visible: :all) + .to have_no_css('.ng-option', text: option, visible: :all, wait: 1) end def expect_option(option) From 7e052786c9f133a8ae76a705f774ea2d377dea9a Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 1 Mar 2024 19:03:56 +0200 Subject: [PATCH 115/218] Filter out projects with disabled custom fields on the projects index page https://community.openproject.org/wp/53007 --- .../projects/filters/custom_field_context.rb | 13 +++++++++---- config/locales/en.yml | 1 + spec/features/projects/projects_index_spec.rb | 9 +++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/models/queries/projects/filters/custom_field_context.rb b/app/models/queries/projects/filters/custom_field_context.rb index 34fad024bf30..55199abde705 100644 --- a/app/models/queries/projects/filters/custom_field_context.rb +++ b/app/models/queries/projects/filters/custom_field_context.rb @@ -44,10 +44,15 @@ def where_subselect_joins(custom_field) cv_db_table = CustomValue.table_name project_db_table = Project.table_name - "LEFT OUTER JOIN #{cv_db_table} - ON #{cv_db_table}.customized_type='Project' - AND #{cv_db_table}.customized_id=#{project_db_table}.id - AND #{cv_db_table}.custom_field_id=#{custom_field.id}" + <<~SQL.squish + LEFT OUTER JOIN #{cv_db_table} + ON #{cv_db_table}.customized_type='Project' + AND #{cv_db_table}.customized_id=#{project_db_table}.id + AND #{cv_db_table}.custom_field_id=#{custom_field.id} + INNER JOIN project_custom_field_project_mappings + ON project_custom_field_project_mappings.project_id = projects.id + AND project_custom_field_project_mappings.custom_field_id = #{custom_field.id} + SQL end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 663f0c2b869a..c26452718c1c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2161,6 +2161,7 @@ Project attributes and sections are defined in the Date: Mon, 4 Mar 2024 18:54:08 +0700 Subject: [PATCH 116/218] fixes XLS export specs --- .../project/exporter/xls_integration_spec.rb | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb index 8ad614ee09ae..1671658e5328 100644 --- a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb +++ b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb @@ -33,7 +33,8 @@ context 'with project description containing html' do before do - project.update(description: "This is an

html

description.") + project.description = "This is an

html

description." + project.save!(validate: false) end it 'performs a successful export' do @@ -58,29 +59,36 @@ describe 'custom field columns selected' do before do - Setting.enabled_projects_columns += custom_fields.map(&:column_name) + Setting.enabled_projects_columns += global_project_custom_fields.map(&:column_name) end context 'when ee enabled', with_ee: %i[custom_fields_in_projects_list] do it 'renders all those columns' do expect(rows.count).to eq 1 - cf_names = custom_fields.map(&:name) + cf_names = global_project_custom_fields.map(&:name) expect(header).to eq ['ID', 'Identifier', 'Name', 'Description', 'Status', 'Public', *cf_names] - custom_values = custom_fields.map do |cf| + custom_values = global_project_custom_fields.map do |cf| case cf when bool_cf 'true' when text_cf project.typed_custom_value_for(cf) + when not_used_string_cf + nil else project.formatted_custom_value_for(cf) end end expect(sheet.row(1)) - .to eq [project.id.to_s, project.identifier, project.name, project.description, 'Off track', 'false', *custom_values] + .to eq [project.id.to_s, project.identifier, project.name, project.description, 'Off track', 'false', + *custom_values] + + # The column for the project-level-disabled custom field is blank + expect(sheet.row(1)[header.index(not_used_string_cf.name)]).to be_nil + # TODO: CSV export renders "" instead of nil, why does XLS export render nil? end end From a67359f52e31c938a424ee26fd23f9a7c651817b Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Mar 2024 20:26:48 +0700 Subject: [PATCH 117/218] fixed custom value specs --- spec/models/custom_value_spec.rb | 87 +++++++++++++++++++------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/spec/models/custom_value_spec.rb b/spec/models/custom_value_spec.rb index e9476f488ea2..eb97bddbccd2 100644 --- a/spec/models/custom_value_spec.rb +++ b/spec/models/custom_value_spec.rb @@ -150,7 +150,7 @@ end context "for a string custom field without default value" do - shared_let(:custom_field) { create(:project_custom_field, :string) } + shared_let(:custom_field) { create(:project_custom_field, :string, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: 'Hello world!' @@ -159,8 +159,8 @@ context 'for a string custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :string, default_value: 'Hello world!') - create(:project_custom_field, :string, default_value: '') + create(:project_custom_field, :string, default_value: 'Hello world!', projects: [project]) + create(:project_custom_field, :string, default_value: '', projects: [project]) custom_values = project.custom_field_values @@ -170,7 +170,7 @@ end context 'for a text custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :text) } + shared_let(:custom_field) { create(:project_custom_field, :text, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: "Hello world!" @@ -180,8 +180,8 @@ context 'for a text custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :text, default_value: 'Hello world!') - create(:project_custom_field, :text, default_value: '') + create(:project_custom_field, :text, default_value: 'Hello world!', projects: [project]) + create(:project_custom_field, :text, default_value: '', projects: [project]) custom_values = project.custom_field_values @@ -191,7 +191,7 @@ end context 'for an integer custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :integer) } + shared_let(:custom_field) { create(:project_custom_field, :integer, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: 123 @@ -202,11 +202,11 @@ context 'for an integer custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :integer, default_value: 0) - create(:project_custom_field, :integer, default_value: 123) - create(:project_custom_field, :integer, default_value: '456') - create(:project_custom_field, :integer, default_value: -987) - create(:project_custom_field, :integer, default_value: '-678') + create(:project_custom_field, :integer, default_value: 0, projects: [project]) + create(:project_custom_field, :integer, default_value: 123, projects: [project]) + create(:project_custom_field, :integer, default_value: '456', projects: [project]) + create(:project_custom_field, :integer, default_value: -987, projects: [project]) + create(:project_custom_field, :integer, default_value: '-678', projects: [project]) custom_values = project.custom_field_values @@ -216,7 +216,7 @@ end context 'for a float custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :float) } + shared_let(:custom_field) { create(:project_custom_field, :float, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: 3.14 @@ -227,11 +227,11 @@ context 'for a float custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :float, default_value: 0.0) - create(:project_custom_field, :float, default_value: 12.3) - create(:project_custom_field, :float, default_value: '45.6') - create(:project_custom_field, :float, default_value: -98.7) - create(:project_custom_field, :float, default_value: '-67') + create(:project_custom_field, :float, default_value: 0.0, projects: [project]) + create(:project_custom_field, :float, default_value: 12.3, projects: [project]) + create(:project_custom_field, :float, default_value: '45.6', projects: [project]) + create(:project_custom_field, :float, default_value: -98.7, projects: [project]) + create(:project_custom_field, :float, default_value: '-67', projects: [project]) custom_values = project.custom_field_values @@ -241,7 +241,7 @@ end context 'for a date custom field' do - shared_let(:custom_field) { create(:project_custom_field, :date) } + shared_let(:custom_field) { create(:project_custom_field, :date, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: "2023-08-08" @@ -249,7 +249,7 @@ end context 'for a list custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :list) } + shared_let(:custom_field) { create(:project_custom_field, :list, projects: [project]) } include_examples 'returns true for generated custom value' @@ -266,7 +266,7 @@ context 'for a list custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :list, default_option: 'B') + create(:project_custom_field, :list, default_option: 'B', projects: [project]) custom_values = project.custom_field_values @@ -277,7 +277,7 @@ end context 'for a multi-value list custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :multi_list) } + shared_let(:custom_field) { create(:project_custom_field, :multi_list, projects: [project]) } include_examples 'returns true for generated custom value' @@ -294,8 +294,8 @@ context 'for a multi-value list custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :multi_list, default_options: ['B']) - create(:project_custom_field, :multi_list, default_options: ['G', 'B', 'C']) + create(:project_custom_field, :multi_list, default_options: ['B'], projects: [project]) + create(:project_custom_field, :multi_list, default_options: ['G', 'B', 'C'], projects: [project]) custom_values = project.custom_field_values @@ -307,7 +307,7 @@ end context 'for a version custom field' do - shared_let(:custom_field) { create(:project_custom_field, :version) } + shared_let(:custom_field) { create(:project_custom_field, :version, projects: [project]) } include_examples 'returns true for generated custom value' @@ -325,7 +325,7 @@ end context 'for a multi version custom field' do - shared_let(:custom_field) { create(:project_custom_field, :multi_version) } + shared_let(:custom_field) { create(:project_custom_field, :multi_version, projects: [project]) } include_examples 'returns true for generated custom value' @@ -343,7 +343,7 @@ end context 'for a user custom field' do - shared_let(:custom_field) { create(:project_custom_field, :user) } + shared_let(:custom_field) { create(:project_custom_field, :user, projects: [project]) } include_examples 'returns true for generated custom value' @@ -363,7 +363,7 @@ end context 'for a multi user custom field' do - shared_let(:custom_field) { create(:project_custom_field, :multi_user) } + shared_let(:custom_field) { create(:project_custom_field, :multi_user, projects: [project]) } include_examples 'returns true for generated custom value' @@ -397,7 +397,9 @@ end describe '#valid?' do - let(:custom_field) { build_stubbed(:custom_field, field_format:, is_required:, min_length:, max_length:, regexp:) } + let(:custom_field) do + build_stubbed(:custom_field, field_format:, is_required:, min_length:, max_length:, regexp:) + end let(:custom_value) { described_class.new(custom_field:, value:) } let(:is_required) { false } let(:min_length) { 0 } @@ -551,7 +553,9 @@ context 'for a list custom field' do let(:custom_option1) { build_stubbed(:custom_option, value: 'value1') } let(:custom_option2) { build_stubbed(:custom_option, value: 'value1') } - let(:custom_field) { build_stubbed(:custom_field, field_format: 'list', custom_options: [custom_option1, custom_option2]) } + let(:custom_field) do + build_stubbed(:custom_field, field_format: 'list', custom_options: [custom_option1, custom_option2]) + end context 'with a value from the list' do let(:value) { custom_option1.id } @@ -733,12 +737,25 @@ describe '#activate_custom_field_in_customized_project' do let(:custom_field) { create(:project_custom_field) } let(:project) { create(:project) } - let(:custom_value) { build(:custom_value, custom_field:, customized: project) } - it 'activates the custom field in the project after create if missing' do - expect(project.project_custom_fields).not_to include(custom_field) - custom_value.save! - expect(project.reload.project_custom_fields).to include(custom_field) + context 'when a value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "foo") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).to include(custom_field) + end + end + + context 'when a value is not set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + + it 'does not activate the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end end end end From 83afa78ce1bb9d3d2ccc1957c88ec824a6273b1e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 4 Mar 2024 20:44:55 +0700 Subject: [PATCH 118/218] fixing unit tests, mark last failing one as pending --- config/locales/en.yml | 1 + .../app/controllers/overviews/overviews_controller.rb | 2 ++ spec/permissions/manage_project_custom_values_spec.rb | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 81978080afb5..f1da808dce05 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2682,6 +2682,7 @@ Project attributes and sections are defined in the
Date: Mon, 4 Mar 2024 19:29:53 +0200 Subject: [PATCH 119/218] Rename "Custom field section" to "Section" on attribute form --- config/locales/en.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index f1da808dce05..d3908331a748 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -719,6 +719,8 @@ Project attributes and sections are defined in the Date: Mon, 4 Mar 2024 20:15:38 +0200 Subject: [PATCH 120/218] Replace the Project details widget with deprecation message --- .../project-details.component.html | 25 ++--- .../project-details.component.ts | 60 +--------- .../spec/features/project_details_spec.rb | 104 ++++++------------ 3 files changed, 46 insertions(+), 143 deletions(-) diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html index 5e35609e7036..a37c74d9be50 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html @@ -9,22 +9,11 @@ diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts index e6caae2e84c4..ba1022c6eb0d 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts @@ -32,81 +32,31 @@ import { Component, ElementRef, Injector, - OnInit, ViewChild, } from '@angular/core'; import { AbstractWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-widget.component'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; -import { Observable } from 'rxjs'; -import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; -import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { SchemaAttributeObject } from 'core-app/features/hal/resources/schema-attribute-object'; @Component({ templateUrl: './project-details.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - HalResourceEditingService, - ], }) -export class WidgetProjectDetailsComponent extends AbstractWidgetComponent implements OnInit { +export class WidgetProjectDetailsComponent extends AbstractWidgetComponent { @ViewChild('contentContainer', { static: true }) readonly contentContainer:ElementRef; - public customFields:{ key:string, label:string }[] = []; - - public project$:Observable; - - constructor(protected readonly i18n:I18nService, + constructor( + protected readonly i18n:I18nService, protected readonly injector:Injector, protected readonly apiV3Service:ApiV3Service, protected readonly currentProject:CurrentProjectService, - protected readonly cdRef:ChangeDetectorRef) { + protected readonly cdRef:ChangeDetectorRef, + ) { super(i18n, injector); } - ngOnInit():void { - this.loadAndRender(); - if (this.currentProject.id) { - this.project$ = this - .apiV3Service - .projects - .id(this.currentProject.id) - .requireAndStream(); - } - } - public get isEditable():boolean { return false; } - - private loadAndRender():void { - void Promise.all([ - this.loadProjectSchema(), - ]) - .then(([schema]) => { - this.setCustomFields(schema); - }); - } - - private loadProjectSchema():Promise { - return this - .apiV3Service - .projects - .schema - .get() - .toPromise(); - } - - private setCustomFields(schema:SchemaResource) { - Object.entries(schema).forEach(([key, keySchema]) => { - if (/customField\d+/.exec(key)) { - this.customFields.push({ key, label: (keySchema as SchemaAttributeObject).name }); - } - }); - - this.cdRef.detectChanges(); - } } diff --git a/modules/dashboards/spec/features/project_details_spec.rb b/modules/dashboards/spec/features/project_details_spec.rb index 5854a2a3a9de..3957ba2c4cb7 100644 --- a/modules/dashboards/spec/features/project_details_spec.rb +++ b/modules/dashboards/spec/features/project_details_spec.rb @@ -31,30 +31,10 @@ require_relative '../support/pages/dashboard' RSpec.describe 'Project details widget on dashboard', :js do - let!(:version_cf) { create(:version_project_custom_field) } - let!(:bool_cf) { create(:boolean_project_custom_field) } - let!(:user_cf) { create(:user_project_custom_field) } - let!(:int_cf) { create(:integer_project_custom_field) } - let!(:float_cf) { create(:float_project_custom_field) } - let!(:text_cf) { create(:text_project_custom_field) } - let!(:string_cf) { create(:string_project_custom_field) } - let!(:date_cf) { create(:date_project_custom_field) } - let(:system_version) { create(:version, sharing: 'system') } let!(:project) do - create(:project, members: { other_user => role }).tap do |p| - p.send(int_cf.attribute_setter, 5) - p.send(bool_cf.attribute_setter, true) - p.send(version_cf.attribute_setter, system_version) - p.send(float_cf.attribute_setter, 4.5) - p.send(text_cf.attribute_setter, 'Some **long** text') - p.send(string_cf.attribute_setter, 'Some small text') - p.send(date_cf.attribute_setter, Date.current) - p.send(user_cf.attribute_setter, other_user) - - p.save!(validate: false) - end + create(:project, members: { other_user => role }) end let(:permissions) do @@ -98,22 +78,6 @@ def add_project_details_widget dashboard_page.expect_and_dismiss_toaster message: I18n.t('js.notice_successful_update') end - def change_cf_value(cf, old_value, new_value) - # Open description field - cf.activate! - sleep(0.1) - - # Change the value - cf.expect_value(old_value) - cf.set_value new_value - cf.save! unless cf.field_type === 'create-autocompleter' - - # The edit field is toggled and the value saved. - expect(page).to have_content(new_value) - expect(page).to have_selector(cf.selector) - expect(page).to have_no_selector(cf.input_selector) - end - before do login_as current_user add_project_details_widget @@ -122,33 +86,25 @@ def change_cf_value(cf, old_value, new_value) context 'without editing permissions' do let(:current_user) { read_only_user } - it 'can add the widget, but not edit the custom fields' do + it 'displays the deprecated message' do # As the user lacks the manage_public_queries and save_queries permission, no other widget is present details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') within(details_widget.area) do - # Expect values - expect(page) - .to have_content("#{int_cf.name}\n5") - expect(page) - .to have_content("#{bool_cf.name}\nyes") - expect(page) - .to have_content("#{version_cf.name}\n#{system_version.name}") - expect(page) - .to have_content("#{float_cf.name}\n4.5") - expect(page) - .to have_content("#{text_cf.name}\nSome long text") expect(page) - .to have_content("#{string_cf.name}\nSome small text") - expect(page) - .to have_content("#{date_cf.name}\n#{Date.today.strftime('%m/%d/%Y')}") - expect(page) - .to have_content("#{user_cf.name}\n#{other_user.name.split.map(&:first).join}\n#{other_user.name}") - - # The fields are not editable - field = EditField.new dashboard_page, bool_cf.attribute_name(:camel_case) - field.expect_read_only - field.activate! expect_open: false + .to have_content("Project details have now moved to a column on the right edge of this page.") + expect(page).to have_content( + <<~TEXT.strip + Starting with version 13.4, project attributes can be grouped \ + in sections and enabled and disabled at a project level. + TEXT + ) + expect(page).to have_content( + <<~TEXT.strip + This widget can now be removed or replaced. \ + It will be deleted in subsequent versions. + TEXT + ) end end end @@ -156,18 +112,26 @@ def change_cf_value(cf, old_value, new_value) context 'with editing permissions' do let(:current_user) { editing_user } - it 'can edit the custom fields' do - int_field = EditField.new dashboard_page, int_cf.attribute_name(:camel_case) - change_cf_value int_field, "5", "3" - - string_field = EditField.new dashboard_page, string_cf.attribute_name(:camel_case) - change_cf_value string_field, 'Some small text', 'Some new text' - - text_field = TextEditorField.new dashboard_page, text_cf.attribute_name(:camel_case) - change_cf_value text_field, 'Some long text', 'Some very long text' + it 'displays the deprecated message' do + # As the user lacks the manage_public_queries and save_queries permission, no other widget is present + details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') - user_field = SelectField.new dashboard_page, user_cf.attribute_name(:camel_case) - change_cf_value user_field, other_user.name, editing_user.name + within(details_widget.area) do + expect(page) + .to have_content("Project details have now moved to a column on the right edge of this page.") + expect(page).to have_content( + <<~TEXT.strip + Starting with version 13.4, project attributes can be grouped \ + in sections and enabled and disabled at a project level. + TEXT + ) + expect(page).to have_content( + <<~TEXT.strip + This widget can now be removed or replaced. \ + It will be deleted in subsequent versions. + TEXT + ) + end end end From 5ed38ee9b19e37e5ec1992ea2763d1c88ed9970e Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 6 Mar 2024 00:22:55 +0200 Subject: [PATCH 121/218] Refactoring on the project act_as_customizable_patch --- .../projects/acts_as_customizable_patches.rb | 99 +++---------------- .../lib/acts_as_customizable.rb | 3 +- spec/models/projects/customizable_spec.rb | 7 -- 3 files changed, 18 insertions(+), 91 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index c544762f485d..332487f950d5 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -106,7 +106,7 @@ def disable_custom_fields_with_empty_values project_custom_field_project_mappings.where(custom_field_id: custom_field_ids).destroy_all end - def query_available_custom_fields_on_global_level + def with_all_available_custom_fields # query the available custom fields on a global level when updating custom field values # in order to support implicit activation of custom fields when values are provided during an update self._query_available_custom_fields_on_global_level = true @@ -116,22 +116,6 @@ def query_available_custom_fields_on_global_level result end - def active_custom_field_ids_of_project - # show all project custom fields in the project creation form - # later on, only those with values will be activated via before_save hook `build_missing_project_custom_field_project_mappings` - # a persisted project will then only show the activated custom fields - # this approach also supports project duplication based on project templates - if new_record? - ProjectCustomField.pluck(:id) - else - project_custom_field_project_mappings.pluck(:custom_field_id) - .concat(ProjectCustomField.required.pluck(:id)) - .uniq - # if for whatever reason a required custom field is not activated for this instance, - # we need to make sure it's treated as activated especially in context of the validation - end - end - def available_custom_fields(global: false) # overrides acts_as_customizable # in contrast to acts_as_customizable, custom_fields are enabled per project @@ -146,8 +130,10 @@ def available_custom_fields(global: false) # # additionally we provide the `global` parameter to allow querying the available custom fields on a global level # when we have explicit control over the call of `available_custom_fields` - unless global || _query_available_custom_fields_on_global_level - custom_fields = custom_fields.where(id: active_custom_field_ids_of_project) + unless global || new_record? || _query_available_custom_fields_on_global_level + custom_fields = custom_fields + .where(id: project_custom_field_project_mappings.select(:custom_field_id)) + .or(ProjectCustomField.required) end custom_fields @@ -158,91 +144,38 @@ def available_project_custom_fields_grouped_by_section .group_by(&:custom_field_section_id) end - def available_custom_fields_by_section(section) - available_custom_fields - .where(custom_field_section_id: section.id) - end - def sorted_available_custom_fields available_custom_fields .sort_by { |pcf| [pcf.project_custom_field_section.position, pcf.position_in_custom_field_section] } end def sorted_available_custom_fields_by_section(section) - available_custom_fields_by_section(section) + available_custom_fields + .where(custom_field_section_id: section.id) .sort_by(&:position_in_custom_field_section) end - def custom_field_section_ids - # we need to check if a project custom field belongs to a specific section when validating - # we need a mapping of custom_field_id => custom_field_section_id as we don't want to - # change the code of acts_as_customizable for `custom_field_values` which does not include the custom_field_section_id - # preloading a hash avoids n+1 queries while validating - CustomField - .where(id: custom_field_values.pluck(:custom_field_id)) - .pluck(:id, :custom_field_section_id) - .to_h - end - def validate_custom_values - # overrides acts_as_customizable # validate custom values only of a specified section # instead of validating ALL custom values like done in acts_as_customizable - set_default_values! if new_record? - - custom_field_values - .select { |custom_value| of_specified_custom_field_section?(custom_value) } - .reject(&:marked_for_destruction?) - .select(&:invalid?) - .each { |custom_value| add_custom_value_errors! custom_value } - end + custom_field_section_ids = CustomField + .where(id: custom_field_values.pluck(:custom_field_id)) + .where(custom_field_section_id: _limit_custom_fields_validation_to_section_id) + .pluck(:id) - def of_specified_custom_field_section?(custom_value) - if _limit_custom_fields_validation_to_section_id.present? - custom_field_section_ids[custom_value.custom_field_id] == _limit_custom_fields_validation_to_section_id - else - true # validate all custom values if no specific section was specified - end + super(custom_field_section_ids) end + # we need to query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update def custom_field_values=(values) - # overrides acts_as_customizable - # we need to query the available custom fields on a global level when updating custom field values - # in order to support implicit activation of custom fields when values are provided during an update - self._query_available_custom_fields_on_global_level = true # set to false in after_save hook - set_custom_field_values_method_from_acts_as_customizable_module(values) - end - - # we cannot call super as the code in acts_as_customizable is shipped in a module - # thus copy and pasted the code from acts_as_customizable here - def set_custom_field_values_method_from_acts_as_customizable_module(values) - return unless values.is_a?(Hash) && values.any? - - values.with_indifferent_access.each do |custom_field_id, val| - existing_cv_by_value = custom_values_for_custom_field(id: custom_field_id) - .group_by(&:value) - .transform_values(&:first) - new_values = Array(val).map { |v| v.respond_to?(:id) ? v.id.to_s : v.to_s } - - if existing_cv_by_value.any? - assign_new_values custom_field_id, existing_cv_by_value, new_values - delete_obsolete_custom_values existing_cv_by_value, new_values - handle_minimum_custom_value custom_field_id, existing_cv_by_value, new_values - end - end + with_all_available_custom_fields { super } end - # overrides acts_as_customizable # we need to query the available custom fields on a global level when # trying to set a custom field which is not enabled via e.g. custom_field_123="foo" def for_custom_field_accessor(method_symbol) - match = /\Acustom_field_(?\d+)=?\z/.match(method_symbol.to_s) - if match - custom_field = available_custom_fields(global: true).find { |cf| cf.id.to_s == match[:id] } - if custom_field - yield custom_field - end - end + with_all_available_custom_fields { super } end end end diff --git a/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb b/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb index a775da0dccd4..a98e168399a2 100644 --- a/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb +++ b/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb @@ -233,11 +233,12 @@ def set_default_values! self.custom_field_values = new_values end - def validate_custom_values + def validate_custom_values(custom_field_ids = []) set_default_values! if new_record? custom_field_values .reject(&:marked_for_destruction?) + .select { |cv| custom_field_ids.empty? || custom_field_ids.include?(cv.custom_field_id) } .select(&:invalid?) .each { |custom_value| add_custom_value_errors! custom_value } end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 6debf0516c04..b33719040f11 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -59,13 +59,6 @@ context 'when persisted' do let(:project) { create(:project) } - describe '#active_custom_field_ids_of_project' do - it 'returns all active custom field ids of the project' do - expect(project.active_custom_field_ids_of_project) - .to be_empty - end - end - describe '#available_custom_fields' do it 'returns only mapped project custom fields as available custom fields' do expect(project.project_custom_field_project_mappings) From 5753baf90c9047913a5594736374ae7f3a11c2dc Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 5 Mar 2024 08:50:30 +0700 Subject: [PATCH 122/218] fix empty project attributes issue seen in specs --- .../index_component.html.erb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/index_component.html.erb b/app/components/settings/project_custom_field_sections/index_component.html.erb index 22c1f3bad99d..68f40c5ed86d 100644 --- a/app/components/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/settings/project_custom_field_sections/index_component.html.erb @@ -1,11 +1,13 @@ <%= component_wrapper(data: wrapper_data_attributes) do - flex_layout(classes: 'dragula-container', data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do |flex| - @project_custom_field_sections.each do |section| - flex.with_row( - data: draggable_item_config(section) - ) do - render(Settings::ProjectCustomFieldSections::ShowComponent.new(project_custom_field_section: section)) + if @project_custom_field_sections.any? + flex_layout(classes: 'dragula-container', data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do |flex| + @project_custom_field_sections.each do |section| + flex.with_row( + data: draggable_item_config(section) + ) do + render(Settings::ProjectCustomFieldSections::ShowComponent.new(project_custom_field_section: section)) + end end end end From 6d8fd7fb36b692757f72ca3103d077b66e499e55 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 5 Mar 2024 09:06:52 +0700 Subject: [PATCH 123/218] fixed and enriched project settings page specs --- spec/features/projects/edit_settings_spec.rb | 41 ++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index f6be75da0e2b..202b6d8b6c82 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -80,15 +80,15 @@ context 'with optional and required custom fields' do let!(:optional_custom_field) do - create(:custom_field, name: 'Optional Foo', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Optional Foo', + is_for_all: true, + projects: [project]) end let!(:required_custom_field) do - create(:custom_field, name: 'Required Foo', - type: ProjectCustomField, - is_for_all: true, - is_required: true) + create(:project_custom_field, name: 'Required Foo', + is_for_all: true, + is_required: true, + projects: [project]) end it 'shows optional and required custom fields for edit without a separation' do @@ -106,10 +106,10 @@ let!(:required_custom_field) do create(:string_project_custom_field, name: 'Foo', - type: ProjectCustomField, min_length: 1, max_length: 2, - is_for_all: true) + is_for_all: true, + projects: [project]) end let(:foo_field) { FormFields::InputFormField.new required_custom_field } @@ -133,7 +133,7 @@ context 'with a multi-select custom field' do include_context 'ng-select-autocomplete helpers' - let!(:list_custom_field) { create(:list_project_custom_field, name: 'List CF', multi_value: true) } + let!(:list_custom_field) { create(:list_project_custom_field, name: 'List CF', multi_value: true, projects: [project]) } let(:form_field) { FormFields::SelectFormField.new list_custom_field } it 'can select multiple values' do @@ -154,7 +154,7 @@ end context 'with a date custom field' do - let!(:date_custom_field) { create(:date_project_custom_field, name: 'Date') } + let!(:date_custom_field) { create(:date_project_custom_field, name: 'Date', projects: [project]) } let(:form_field) { FormFields::InputFormField.new date_custom_field } it 'can save and remove the date (Regression #37459)' do @@ -204,4 +204,23 @@ .to eql parent_project end end + + context 'with correct scoping of project custom fields' do + let!(:optional_custom_field_activated_in_project) do + create(:project_custom_field, name: 'Optional Foo', + is_for_all: true, + projects: [project]) + end + let!(:optional_custom_field_not_activated_in_project) do + create(:project_custom_field, name: 'Optional Bar', + is_for_all: true) + end + + it 'shows only the custom fields that are activated in the project' do + visit project_settings_general_path(project.id) + + expect(page).to have_text 'Optional Foo' + expect(page).to have_no_text 'Optional Bar' + end + end end From 809f9c283d896bbfc5264b8830732abcf6024574 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 5 Mar 2024 09:42:59 +0700 Subject: [PATCH 124/218] fixed dialog specs --- .../project_custom_fields/overview_page/dialog/update_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb index 5da4693b0130..3bde936902a8 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -313,7 +313,7 @@ overview_page.open_edit_dialog_for_section(section) - field.expect_selected(first_option, second_option) # wait for proper initialization + field.expect_selected(first_option) # wait for proper initialization # don't touch the input dialog.submit From 8bc5c138fedbcc93c3b898024e5833fe92e98646 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 6 Mar 2024 12:31:28 +0700 Subject: [PATCH 125/218] changed n/a text --- config/locales/en.yml | 1 - .../show_component.html.erb | 2 +- .../overview_page/sidebar_spec.rb | 58 +++++++++---------- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 6d6c95db78a8..9486f216bd10 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2113,7 +2113,6 @@ Project attributes and sections are defined in the Date: Wed, 6 Mar 2024 12:36:39 +0700 Subject: [PATCH 126/218] adde required label for global project attributes overview --- .../custom_field_row_component.html.erb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index 86ad4a072264..30cac90ed3e2 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -21,11 +21,18 @@ @project_custom_field.field_format.capitalize end end - content_container.with_column do + content_container.with_column(mr: 2) do render(Primer::Beta::Text.new(font_size: :small)) do project_count_text end end + if @project_custom_field.required? + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + t("label_required") + end + end + end end main_container.with_column do render(Primer::Alpha::ActionMenu.new(data: { qa_selector: "project-custom-field-action-menu" })) do |menu| From d74232fc59317278991c8e729f362bcbdfe6f792 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 6 Mar 2024 12:57:42 +0700 Subject: [PATCH 127/218] added parent link for mobile views --- .../edit_form_header_component.html.erb | 5 ++++- .../project_custom_fields/new_form_header_component.html.erb | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb index a9e2c805fac3..4009432e3974 100644 --- a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb +++ b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb @@ -30,6 +30,9 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::OpenProject::PageHeader.new) do |header| header.with_title(variant: :medium) { @custom_field.name } header.with_description { t('settings.project_attributes.edit.description') } + header.with_parent_link(href: admin_settings_project_custom_fields_path, 'aria-label': I18n.t("button_back")) do + t('settings.project_attributes.heading') + end header.with_back_button(href: admin_settings_project_custom_fields_path, 'aria-label': t('button_back')) end -%> +%> \ No newline at end of file diff --git a/app/components/settings/project_custom_fields/new_form_header_component.html.erb b/app/components/settings/project_custom_fields/new_form_header_component.html.erb index 9f551e70af12..1913a74db2a0 100644 --- a/app/components/settings/project_custom_fields/new_form_header_component.html.erb +++ b/app/components/settings/project_custom_fields/new_form_header_component.html.erb @@ -31,6 +31,9 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::OpenProject::PageHeader.new) do |header| header.with_title(variant: :medium) { t('settings.project_attributes.new.heading') } header.with_description { t('settings.project_attributes.new.description') } + header.with_parent_link(href: admin_settings_project_custom_fields_path, 'aria-label': I18n.t("button_back")) do + t('settings.project_attributes.heading') + end header.with_back_button(href: admin_settings_project_custom_fields_path, 'aria-label': t('button_back')) end %> From 9330fecb36b4ee30343fc5de0bd68ecc09db02f9 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 11:36:04 +0700 Subject: [PATCH 128/218] respect visible setting and enriched specs --- .../base_contract.rb | 9 + .../project_custom_fields_controller.rb | 1 + .../projects/acts_as_customizable_patches.rb | 1 + .../bulk_edit_service.rb | 6 +- .../eager_loading/custom_field_accessor.rb | 2 +- .../overview_page/dialog/render_spec.rb | 54 +++++ .../settings/mapping_spec.rb | 99 ++++++++ .../api/v3/projects/create_resource_spec.rb | 44 +++- .../api/v3/projects/show_resource_spec.rb | 31 ++- .../api/v3/projects/update_resource_spec.rb | 50 +++- .../bulk_edit_service_spec.rb | 145 ++++++++++++ .../toggle_service_spec.rb | 217 ++++++++++++++++++ spec/support/pages/projects/show.rb | 1 + 13 files changed, 655 insertions(+), 5 deletions(-) create mode 100644 spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb create mode 100644 spec/services/project_custom_field_project_mappings/toggle_service_spec.rb diff --git a/app/contracts/project_custom_field_project_mappings/base_contract.rb b/app/contracts/project_custom_field_project_mappings/base_contract.rb index fa50115f8bd2..2e18e2beb564 100644 --- a/app/contracts/project_custom_field_project_mappings/base_contract.rb +++ b/app/contracts/project_custom_field_project_mappings/base_contract.rb @@ -33,6 +33,7 @@ class BaseContract < ::ModelContract validate :validate_has_select_project_custom_fields_permission validate :validate_is_not_required + validate :validate_is_visbile_to_user def validate_has_select_project_custom_fields_permission return if user.allowed_in_project?(:select_project_custom_fields, model.project) @@ -47,5 +48,13 @@ def validate_is_not_required errors.add :custom_field_id, :invalid end + + def validate_is_visbile_to_user + # "invisible" custom fields can only be seen and edited by admins + # using visible scope to check if the custom field is actually visible to the user + return if model.project_custom_field.nil? || ProjectCustomField.visible.pluck(:id).include?(model.project_custom_field.id) + + errors.add :custom_field_id, :invalid + end end end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 728fd88f106b..441eb7694107 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -90,6 +90,7 @@ def eager_load_project_custom_field_sections def eager_load_project_custom_fields @project_custom_fields_grouped_by_section = ProjectCustomField + .visible .includes(:project_custom_field_section) .sort_by { |pcf| pcf.project_custom_field_section.position } .group_by(&:custom_field_section_id) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 332487f950d5..5780f1a7853f 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -121,6 +121,7 @@ def available_custom_fields(global: false) # in contrast to acts_as_customizable, custom_fields are enabled per project # thus we need to check the project_custom_field_project_mappings custom_fields = ProjectCustomField + .visible .includes(:project_custom_field_section) # available_custom_fields is called from within the acts_as_customizable module diff --git a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb b/app/services/project_custom_field_project_mappings/bulk_edit_service.rb index 0795b244a7f1..35e2d195483d 100644 --- a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb +++ b/app/services/project_custom_field_project_mappings/bulk_edit_service.rb @@ -71,7 +71,11 @@ def perform_bulk_edit(service_call, params) def fetch_custom_field_ids # only custom fields which are not set to required can be disabled - @project_custom_field_section.custom_fields.where(is_required: false).pluck(:id) + ProjectCustomField + .visible(@user) + .where(custom_field_section_id: @project_custom_field_section.id) + .where(is_required: false) + .pluck(:id) end def enable_custom_fields(custom_field_ids) diff --git a/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb b/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb index cae649d1fe23..8c61d88d8cbe 100644 --- a/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb +++ b/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb @@ -44,7 +44,7 @@ def initialize(object) end module CustomFieldAccessorPatch - def available_custom_fields + def available_custom_fields(global: false) @available_custom_fields end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb index 86c4772d16fa..8bf00e23429c 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb @@ -139,4 +139,58 @@ expect(containers[5].text).to include('Boolean field') end end + + context 'with visibility of project custom fields' do + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Normal field', + visible: true, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_with_invisible_fields) } + + context 'with admin permissions' do + before do + login_as admin + overview_page.visit_page + end + + it 'shows all project custom fields' do + overview_page.open_edit_dialog_for_section(section_with_invisible_fields) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_content('Normal field') + expect(page).to have_content('Admin only field') + end + end + end + + context 'with non-admin permissions' do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + it 'shows only visible project custom fields' do + overview_page.open_edit_dialog_for_section(section_with_invisible_fields) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_content('Normal field') + expect(page).to have_no_content('Admin only field') + end + end + end + end end diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb index f2d434d71c0b..61db89df1e04 100644 --- a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -323,6 +323,105 @@ expect(custom_fields[1].text).to include('Boolean field') end end + + context 'with visibility of project custom fields' do + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Normal field', + visible: true, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + context 'with admin permissions' do + let!(:admin) do + create(:admin) + end + + before do + login_as admin + visit project_settings_project_custom_fields_path(project) + end + + it 'shows the invisible project custom fields' do + within_custom_field_section_container(section_with_invisible_fields) do + expect(page).to have_content('Normal field') + expect(page).to have_content('Admin only field') + end + end + + it 'includeds the invisible project custom fields in the bulk actions' do + within_custom_field_section_container(section_with_invisible_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_unchecked_state + end + within_custom_field_container(invisible_project_custom_field) do + expect_unchecked_state + end + + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_checked_state + end + within_custom_field_container(invisible_project_custom_field) do + expect_checked_state + end + end + end + end + + context 'with non-admin permissions' do + before do + login_as user_with_sufficient_permissions + visit project_settings_project_custom_fields_path(project) + end + + it 'does not show the invisible project custom fields' do + within_custom_field_section_container(section_with_invisible_fields) do + expect(page).to have_content('Normal field') + expect(page).to have_no_content('Admin only field') + end + end + + it 'does not include the invisible project custom fields in the bulk actions' do + within_custom_field_section_container(section_with_invisible_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_unchecked_state + end + + # the invisible field is not affected by the bulk action + expect(project.project_custom_fields).to include(invisible_project_custom_field) + + # disable manually + project.project_custom_field_project_mappings.find_by(custom_field_id: invisible_project_custom_field.id).destroy! + + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_checked_state + end + + # the invisible field is not affected by the bulk action + expect(project.project_custom_fields).not_to include(invisible_project_custom_field) + end + end + end + end end def expect_type(type) diff --git a/spec/requests/api/v3/projects/create_resource_spec.rb b/spec/requests/api/v3/projects/create_resource_spec.rb index 3be223c3fc58..0ff82ecfcbd0 100644 --- a/spec/requests/api/v3/projects/create_resource_spec.rb +++ b/spec/requests/api/v3/projects/create_resource_spec.rb @@ -36,6 +36,9 @@ let(:custom_field) do create(:text_project_custom_field) end + let(:invisible_custom_field) do + create(:text_project_custom_field, visible: false) + end let(:custom_value) do CustomValue.create(custom_field:, value: '1234', @@ -113,7 +116,7 @@ end end - context 'with a custom field' do + context 'with a visible custom field' do let(:body) do { identifier: 'new_project_identifier', @@ -136,6 +139,45 @@ end end + context 'with an invisible custom field' do + let(:body) do + { + identifier: 'new_project_identifier', + name: 'Project name', + invisible_custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + }.to_json + end + + context 'with admin permissions' do + current_user { create(:admin) } + + it 'sets the cf value' do + expect(last_response.body) + .to be_json_eql("CF text".to_json) + .at_path("customField#{invisible_custom_field.id}/raw") + end + + it 'automatically activates the cf for project if the value was provided' do + expect(Project.last.project_custom_fields) + .to contain_exactly(invisible_custom_field) + end + end + + context 'with non-admin permissions' do + it 'does not set the cf value' do + expect(last_response.body) + .not_to have_json_path("customField#{invisible_custom_field.id}/raw") + end + + it 'does not activate the cf for project' do + expect(Project.last.project_custom_fields) + .to be_empty + end + end + end + context 'without permission to create projects' do let(:permissions) { [] } diff --git a/spec/requests/api/v3/projects/show_resource_spec.rb b/spec/requests/api/v3/projects/show_resource_spec.rb index a27fd4958c87..7fe2ac10cab9 100644 --- a/spec/requests/api/v3/projects/show_resource_spec.rb +++ b/spec/requests/api/v3/projects/show_resource_spec.rb @@ -53,6 +53,14 @@ value: '1234', customized: project) end + let(:invisible_custom_field) do + create(:text_project_custom_field, visible: false) + end + let(:invisible_custom_value) do + CustomValue.create(custom_field: invisible_custom_field, + value: '1234', + customized: project) + end let(:get_path) { api_v3_paths.project project.id } let!(:parent_project) do @@ -96,12 +104,33 @@ .at_path('_links/ancestors/0/href') end - it 'includes custom fields' do + it 'includes only visible custom fields' do custom_value + invisible_custom_value expect(subject.body) .to be_json_eql(custom_value.value.to_json) .at_path("customField#{custom_field.id}/raw") + + expect(subject.body) + .not_to have_json_path("customField#{invisible_custom_field.id}/raw") + end + + context 'with admin permissions' do + current_user { admin } + + it 'includes invisible custom fields' do + custom_value + invisible_custom_value + + expect(subject.body) + .to be_json_eql(custom_value.value.to_json) + .at_path("customField#{custom_field.id}/raw") + + expect(subject.body) + .to be_json_eql(invisible_custom_value.value.to_json) + .at_path("customField#{invisible_custom_field.id}/raw") + end end it 'includes the project status' do diff --git a/spec/requests/api/v3/projects/update_resource_spec.rb b/spec/requests/api/v3/projects/update_resource_spec.rb index e4cf940bc814..fe1a05c6b21f 100644 --- a/spec/requests/api/v3/projects/update_resource_spec.rb +++ b/spec/requests/api/v3/projects/update_resource_spec.rb @@ -44,6 +44,9 @@ let(:custom_field) do create(:text_project_custom_field) end + let(:invisible_custom_field) do + create(:text_project_custom_field, visible: false) + end let(:custom_value) do CustomValue.create(custom_field:, value: '1234', @@ -89,7 +92,7 @@ .at_path('name') end - context 'with a custom field' do + context 'with a visible custom field' do let(:body) do { custom_field.attribute_name(:camel_case) => { @@ -113,6 +116,51 @@ end end + context 'with an invisible custom field' do + let(:body) do + { + invisible_custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + context 'with admin permissions' do + let(:current_user) { create(:admin) } + + it 'responds with 200 OK' do + expect(last_response.status).to eq(200) + end + + it 'sets the cf value' do + expect(project.reload.send(invisible_custom_field.attribute_getter)) + .to eql("CF text") + end + + it 'automatically activates the cf for project if the value was provided' do + expect(project.reload.project_custom_fields) + .to contain_exactly(invisible_custom_field) + end + end + + context 'with non-admin permissions' do + it 'responds with 200 OK' do + # TBD: trying to set a not accessible custom field is silently ignored + expect(last_response.status).to eq(200) + end + + it 'does not set the cf value' do + expect(project.reload.custom_values) + .to be_empty + end + + it 'does not activate the cf for project' do + expect(project.reload.project_custom_fields) + .to be_empty + end + end + end + context 'without permission to patch projects' do let(:permissions) { [] } diff --git a/spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb b/spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb new file mode 100644 index 000000000000..b525697144ee --- /dev/null +++ b/spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb @@ -0,0 +1,145 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings::BulkEditService do + let!(:project) { create(:project) } + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Visible field', + visible: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:visible_required_project_custom_field) do + create(:project_custom_field, + name: 'Visible required field', + visible: true, + is_required: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + project_custom_field_section: section_with_invisible_fields) + end + + let(:instance) { described_class.new(user:, project:, project_custom_field_section: section_with_invisible_fields) } + + context 'with admin permissions' do + let(:user) { create(:admin) } + + it 'bulk enables/disables all (non-required) fields of the section, including invisible ones' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :enable)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field, invisible_project_custom_field + ) + + expect(instance.call(action: :disable)).to be_success + + # required fields cannot be disabled, even not by admins + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end + + context 'with non-admin but sufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Admin', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + it 'bulk enables/disables all fields of the section, excluding invisible ones' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :enable)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field + ) + + project.project_custom_fields << invisible_project_custom_field + + expect(instance.call(action: :disable)).to be_success + + # required fields cannot be disabled, invisible fields are not affected by non-admins + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, invisible_project_custom_field + ) + end + end + + context 'with insufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Editor', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + ] + }) + end + + it 'cannot bulk enable/disable project custom fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :enable)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :disable)).to be_failure + end + end +end diff --git a/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb new file mode 100644 index 000000000000..6b175eb9abb3 --- /dev/null +++ b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb @@ -0,0 +1,217 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMappings::ToggleService do + let!(:project) { create(:project) } + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Visible field', + visible: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:visible_required_project_custom_field) do + create(:project_custom_field, + name: 'Visible required field', + visible: true, + is_required: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + project_custom_field_section: section_with_invisible_fields) + end + + let(:instance) { described_class.new(user:) } + + context 'with admin permissions' do + let(:user) { create(:admin) } + + it 'toggles visible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'toggles invisible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, invisible_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_required_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end + + context 'with non-admin but sufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Admin', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + it 'toggles visible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle invisible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_required_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end + + context 'with insufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Editor', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + ] + }) + end + + it 'toggles visible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle invisible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_required_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end +end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index be5c23304d1a..5ad63c542823 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -72,6 +72,7 @@ def within_custom_field_container(custom_field, &) def open_edit_dialog_for_section(section) within_async_loaded_sidebar do + scroll_to_element(page.find("[data-qa-selector='project-custom-field-section-#{section.id}']")) within_custom_field_section_container(section) do page.find("[data-qa-selector='project-custom-field-section-edit-button']").click end From 228e0db7faf00bf94259d2d79882ce2eb6cd92cf Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 12:01:01 +0700 Subject: [PATCH 129/218] revert temporary change --- lib/api/v3/utilities/eager_loading/custom_field_accessor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb b/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb index 8c61d88d8cbe..cae649d1fe23 100644 --- a/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb +++ b/lib/api/v3/utilities/eager_loading/custom_field_accessor.rb @@ -44,7 +44,7 @@ def initialize(object) end module CustomFieldAccessorPatch - def available_custom_fields(global: false) + def available_custom_fields @available_custom_fields end From aeba29042835a008e7b15863d5b135675aa9d615 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 12:03:07 +0700 Subject: [PATCH 130/218] fixed custom value specs --- spec/models/custom_value_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/custom_value_spec.rb b/spec/models/custom_value_spec.rb index eb97bddbccd2..a2959f03467a 100644 --- a/spec/models/custom_value_spec.rb +++ b/spec/models/custom_value_spec.rb @@ -126,7 +126,7 @@ end context 'for a boolean custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :boolean) } + shared_let(:custom_field) { create(:project_custom_field, :boolean, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: false From 782038b753cbbef9145e4ba6c02bba517e09db54 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 12:45:37 +0700 Subject: [PATCH 131/218] fixed dialog specs --- app/forms/custom_fields/inputs/text.rb | 13 +++++++------ .../overview_page/dialog/update_spec.rb | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/forms/custom_fields/inputs/text.rb b/app/forms/custom_fields/inputs/text.rb index d0645a41956c..4a6ffe3ec853 100644 --- a/app/forms/custom_fields/inputs/text.rb +++ b/app/forms/custom_fields/inputs/text.rb @@ -28,11 +28,12 @@ class CustomFields::Inputs::Text < CustomFields::Inputs::Base::Input form do |custom_value_form| - # TODO: rich_text_area not working yet - # Uncaught DOMException: Failed to execute 'querySelector' on 'Element': '#project_project[new_custom_field_values_attributes][xyz][value]' is not a valid selector. - # --> rich_text_area is not using the configured id, which is not scoped to model via base_config - # --> ids with '[' ']' are not valid selectors - # using simple text area for now - custom_value_form.rich_text_area(**input_attributes) + custom_value_form.rich_text_area(**input_attributes.merge(rich_text_options:)) + end + + def rich_text_options + { + resource: nil + } end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb index 3bde936902a8..85d7d638f553 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -51,7 +51,7 @@ overview_page.visit_page overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Not set yet" + expect(page).to have_content I18n.t('placeholders.default') end overview_page.open_edit_dialog_for_section(section) @@ -108,7 +108,7 @@ overview_page.visit_page overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Not set yet" + expect(page).to have_content I18n.t('placeholders.default') end overview_page.open_edit_dialog_for_section(section) @@ -155,7 +155,7 @@ dialog.expect_closed overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content "Not set yet" + expect(page).to have_content I18n.t('placeholders.default') end end end From dfb3635e8e59801b61f4c6da76e99a6c922b940e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 14:07:19 +0700 Subject: [PATCH 132/218] fixed eslint issues --- .../features/overview/overview.component.ts | 8 +++---- .../op-autocompleter.component.ts | 4 ++-- .../grids/grid/page/grid-page.component.ts | 8 ++++--- ...custom-fields-mapping-filter.controller.ts | 22 +++++++++---------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index ddd9d5d461c1..9746c96c12a5 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -14,18 +14,18 @@ export class OverviewComponent extends GridPageComponent { } protected isTurboFrameSidebarEnabled():boolean { - return true + return true; } protected turboFrameSidebarSrc():string { - return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier!}/project_custom_fields_sidebar`; + return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier ?? ''}/project_custom_fields_sidebar`; } protected turboFrameSidebarId():string { - return "project-custom-fields-sidebar"; + return 'project-custom-fields-sidebar'; } protected gridScopePath():string { - return this.pathHelper.projectPath(this.currentProject.identifier!); + return this.pathHelper.projectPath(this.currentProject.identifier ?? ''); } } diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts index 705dfd744d5d..2973248a39f4 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts @@ -106,7 +106,7 @@ export class OpAutocompleterComponent { this.resetFilterViaClearButton(); }); } - disconnect(): void { + disconnect():void { this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.removeEventListener('click', () => { this.resetFilterViaClearButton(); }); } - filterLists(event: Event) { + filterLists() { const query = this.filterTarget.value.toLowerCase(); if (query.length > 0) { @@ -63,12 +63,12 @@ export default class ProjectCustomFieldsMappingFilterController extends Controll } this.searchItemTargets.forEach((item) => { - const text = item.textContent!.toLowerCase(); + const text = item.textContent?.toLowerCase(); - if (text.includes(query)) { - (item as HTMLElement).style.display = "block"; + if (text?.includes(query)) { + (item as HTMLElement).style.display = 'block'; } else { - (item as HTMLElement).style.display = "none"; + (item as HTMLElement).style.display = 'none'; } }); } @@ -77,19 +77,19 @@ export default class ProjectCustomFieldsMappingFilterController extends Controll this.showBulkActionContainers(); this.searchItemTargets.forEach((item) => { - (item as HTMLElement).style.display = "block"; + (item as HTMLElement).style.display = 'block'; }); } hideBulkActionContainers() { this.bulkActionContainerTargets.forEach((item) => { - (item as HTMLElement).style.display = "none"; + (item as HTMLElement).style.display = 'none'; }); } showBulkActionContainers() { this.bulkActionContainerTargets.forEach((item) => { - (item as HTMLElement).style.display = "block"; + (item as HTMLElement).style.display = 'block'; }); } } From 7071b5e75c05bb527d712eb1163f43554ca0e772 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 14:07:35 +0700 Subject: [PATCH 133/218] fixing admin menu specs --- spec/features/menu_items/admin_menu_item_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/features/menu_items/admin_menu_item_spec.rb b/spec/features/menu_items/admin_menu_item_spec.rb index 6067eb2bcb4a..d3e624593564 100644 --- a/spec/features/menu_items/admin_menu_item_spec.rb +++ b/spec/features/menu_items/admin_menu_item_spec.rb @@ -45,8 +45,8 @@ context 'without having any menu items hidden in configuration' do it 'must display all menu items' do expect(page).to have_test_selector('menu-blocks--container') - expect(page).to have_test_selector('menu-block', count: 21) - expect(page).to have_test_selector('op-menu--item-action', count: 22) # All plus 'overview' + expect(page).to have_test_selector('menu-block', count: 22) + expect(page).to have_test_selector('op-menu--item-action', count: 23) # All plus 'overview' end end @@ -56,10 +56,10 @@ } do it 'must not display the hidden menu items and blocks' do expect(page).to have_test_selector('menu-blocks--container') - expect(page).to have_test_selector('menu-block', count: 20) + expect(page).to have_test_selector('menu-block', count: 21) expect(page).not_to have_test_selector('menu-block', text: I18n.t(:label_color_plural)) - expect(page).to have_test_selector('op-menu--item-action', count: 21) # All plus 'overview' + expect(page).to have_test_selector('op-menu--item-action', count: 22) # All plus 'overview' expect(page).not_to have_test_selector('op-menu--item-action', text: I18n.t(:label_color_plural)) end end From 3e22803728ee4af1b0d67f61d874b5187c01317b Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 14:07:55 +0700 Subject: [PATCH 134/218] trying to resolve issues only seen in CI --- app/models/projects/acts_as_customizable_patches.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 5780f1a7853f..41fbda4b9c89 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -147,7 +147,7 @@ def available_project_custom_fields_grouped_by_section def sorted_available_custom_fields available_custom_fields - .sort_by { |pcf| [pcf.project_custom_field_section.position, pcf.position_in_custom_field_section] } + .sort_by { |pcf| [pcf.project_custom_field_section&.position, pcf.position_in_custom_field_section] } end def sorted_available_custom_fields_by_section(section) From a63fb135741e01f1a08b4fbfbd5912fd458be3d3 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 14:11:21 +0700 Subject: [PATCH 135/218] remove project custom field specs from other custom field feature specs as they have been moved --- spec/features/custom_fields/custom_fields_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/features/custom_fields/custom_fields_spec.rb b/spec/features/custom_fields/custom_fields_spec.rb index eafa5778e4a8..ee07c28bde2e 100644 --- a/spec/features/custom_fields/custom_fields_spec.rb +++ b/spec/features/custom_fields/custom_fields_spec.rb @@ -183,10 +183,6 @@ def expect_page_not_to_have_texts(*text) end end - describe 'projects' do - it_behaves_like "creating a new custom field", 'Projects' - end - describe 'work packages' do it_behaves_like "creating a new custom field", 'Work packages' end From 7a9daf19a30c6050239a82c51d2d3ad9a55f6f36 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 18:26:51 +0700 Subject: [PATCH 136/218] fix CI issues due to render error in sidebar when no section was assigned --- spec/features/projects/copy_spec.rb | 11 ++--- spec/features/projects/create_spec.rb | 59 ++++++++++++++------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 54da1af66632..d15e877638c5 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -58,14 +58,15 @@ roles: [role]) project end + let!(:project_custom_field_section) { create(:project_custom_field_section, name: 'Section A') } let!(:project_custom_field) do - create(:text_project_custom_field, is_required: true) + create(:text_project_custom_field, is_required: true, project_custom_field_section:) end let!(:optional_project_custom_field) do - create(:text_project_custom_field, is_required: false) + create(:text_project_custom_field, is_required: false, project_custom_field_section:) end let!(:optional_project_custom_field_with_default) do - create(:text_project_custom_field, is_required: false, default_value: 'foo') + create(:text_project_custom_field, is_required: false, default_value: 'foo', project_custom_field_section:) end let!(:wp_custom_field) do create(:text_wp_custom_field) @@ -242,10 +243,10 @@ context 'with project custom fields with default values, which are disabled in source project' do let!(:optional_boolean_project_custom_field_with_default) do - create(:boolean_project_custom_field, is_required: false, default_value: true) + create(:boolean_project_custom_field, is_required: false, default_value: true, project_custom_field_section:) end let!(:optional_string_project_custom_field_with_default) do - create(:string_project_custom_field, is_required: false, default_value: 'bar') + create(:string_project_custom_field, is_required: false, default_value: 'bar', project_custom_field_section:) end # TBD: Is this intended from a conceptial point of view? diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index 9ecb723e610d..0d1199b6baaa 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -32,6 +32,7 @@ :js, :with_cuprite do shared_let(:name_field) { FormFields::InputFormField.new :name } + shared_let(:project_custom_field_section) { create(:project_custom_field_section, name: 'Section A') } current_user { create(:admin) } @@ -74,7 +75,9 @@ end context 'with a multi-select custom field' do - let!(:list_custom_field) { create(:list_project_custom_field, name: 'List CF', multi_value: true) } + let!(:list_custom_field) do + create(:list_project_custom_field, name: 'List CF', multi_value: true, project_custom_field_section:) + end let(:list_field) { FormFields::SelectFormField.new list_custom_field } it 'can create a project' do @@ -110,17 +113,17 @@ context 'with optional and required custom fields' do let!(:optional_custom_field) do - create(:custom_field, name: 'Optional Foo', - field_format: 'string', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Optional Foo', + field_format: 'string', + is_for_all: true, + project_custom_field_section:) end let!(:required_custom_field) do - create(:custom_field, name: 'Required Foo', - field_format: 'string', - type: ProjectCustomField, - is_for_all: true, - is_required: true) + create(:project_custom_field, name: 'Required Foo', + field_format: 'string', + is_for_all: true, + is_required: true, + project_custom_field_section:) end it 'separates optional and required custom fields for new' do @@ -151,10 +154,10 @@ context 'with correct custom field activation' do let!(:unused_custom_field) do - create(:custom_field, name: 'Unused Foo', - field_format: 'string', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Unused Foo', + field_format: 'string', + is_for_all: true, + project_custom_field_section:) end before do @@ -183,11 +186,11 @@ context 'with correct handling of default values' do let!(:custom_field_with_default_value) do - create(:custom_field, name: 'Foo with default value', - field_format: 'string', - default_value: 'Default value', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Foo with default value', + field_format: 'string', + default_value: 'Default value', + is_for_all: true, + project_custom_field_section:) end before do @@ -251,18 +254,18 @@ context 'with correct handling of optional boolean values' do let!(:custom_boolean_field_default_true) do - create(:custom_field, name: 'Boolean with default true', - field_format: 'bool', - default_value: true, - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Boolean with default true', + field_format: 'bool', + default_value: true, + is_for_all: true, + project_custom_field_section:) end let!(:custom_boolean_field_with_no_default) do - create(:custom_field, name: 'Boolean with no default', - field_format: 'bool', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Boolean with no default', + field_format: 'bool', + is_for_all: true, + project_custom_field_section:) end before do From 0c3db8a08c813c76b3d3137feddaca3afb11a141 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 22:56:50 +0700 Subject: [PATCH 137/218] adjusted copy service to not activate project custom fields with default values which are disabled in source --- app/services/projects/copy_service.rb | 21 +++++ spec/features/projects/copy_spec.rb | 85 ++++++++++++++++--- .../projects/copy_service_integration_spec.rb | 15 ++++ 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb index 97863455514c..02199b83b0c5 100644 --- a/app/services/projects/copy_service.rb +++ b/app/services/projects/copy_service.rb @@ -80,6 +80,27 @@ def before_perform(params, service_call) end end + def after_perform(call) + remove_custom_fields_not_activated_in_source(call) + + super + end + + def remove_custom_fields_not_activated_in_source(call) + # TODO: seems a bit too hacky to me, find better solution + # remove custom fields from target project which are not activated in source project + # this is required in cases, when + # a custom field with a default value exists but is not activated in the source project + # this custom field is then not shown in the copy form (which is desired) + # but the custom field would be activated in the target project with its default value (which is hard to prevent) + # thus we clean them up here: + custom_fields_activated_in_source = source.project_custom_fields.pluck(:id) + custom_fields_activated_in_target = call.result.project_custom_fields.pluck(:id) + + custom_fields_to_remove = custom_fields_activated_in_target - custom_fields_activated_in_source + call.result.project_custom_fields.where(id: custom_fields_to_remove).destroy_all + end + def contract_options { copy_source: source, validate_model: true } end diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index d15e877638c5..db69f6c59ed0 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -245,16 +245,14 @@ let!(:optional_boolean_project_custom_field_with_default) do create(:boolean_project_custom_field, is_required: false, default_value: true, project_custom_field_section:) end + let!(:optional_boolean_project_custom_field_with_no_default) do + create(:boolean_project_custom_field, is_required: false, project_custom_field_section:) + end let!(:optional_string_project_custom_field_with_default) do create(:string_project_custom_field, is_required: false, default_value: 'bar', project_custom_field_section:) end - # TBD: Is this intended from a conceptial point of view? - # - # If not, I don't know how to change this behavior while keeping the behavior specified in the creation spec where - # optional custom fields with default values are activated if the value is untouched in the form (which seems to be desired from - # a concpetional point of view) - it 'does enable optional project custom fields with default values although not enabled in source project' do + it 'does not enable optional project custom fields with default values when not enabled in source project' do # the optional boolean and string fields are not activated in the source project expect(project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -268,17 +266,82 @@ copied_project = Project.find_by(name: 'Copied project') - # the optional boolean and string fields are activated in the copied project with their default values + # the optional boolean and string fields are not activated in the target project, although they have a default value + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + end + end + end + + context 'with correct handling of invisible values' do + let!(:invisible_field) do + create(:string_project_custom_field, name: 'Text for Admins only', + visible: false, + project_custom_field_section:, + projects: [project]) + end + let!(:source_custom_value_for_invisible_field) do + create(:custom_value, customized: project, custom_field: invisible_field, value: 'foo') + end + + before do + original_settings_page = Pages::Projects::Settings.new(project) + original_settings_page.visit! + + find('.toolbar a', text: 'Copy').click + + expect(page).to have_text "Copy project \"#{project.name}\"" + + fill_in 'Name', with: 'Copied project' + click_on 'Advanced settings' + end + + context 'with an admin user' do + let(:user) { create(:admin) } + + it 'shows invisible fields in the form and allows their activation' do + expect(page).to have_content 'Text for Admins only' + + # don't touch the source value + + click_button 'Save' + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: 'Copied project') + expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, optional_project_custom_field.id, optional_project_custom_field_with_default.id, - optional_boolean_project_custom_field_with_default.id, - optional_string_project_custom_field_with_default.id + invisible_field.id + ) + + expect(copied_project.custom_value_for(invisible_field).typed_value).to eq('foo') + end + end + + context 'with non-admin user' do + # TBD: Not sure if this is the desired behavior, but would be a bit tricky to change + it 'does not show invisible fields in the form and thus do not activate them' do + expect(page).to have_no_content 'Text for Admins only' + + click_button 'Save' + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: 'Copied project') + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id ) - expect(copied_project.custom_value_for(optional_boolean_project_custom_field_with_default).typed_value).to be_truthy - expect(copied_project.custom_value_for(optional_string_project_custom_field_with_default).typed_value).to eq('bar') + expect(copied_project.custom_value_for(invisible_field)).to be_nil end end end diff --git a/spec/services/projects/copy_service_integration_spec.rb b/spec/services/projects/copy_service_integration_spec.rb index d48b613d147f..c95a328eb85e 100644 --- a/spec/services/projects/copy_service_integration_spec.rb +++ b/spec/services/projects/copy_service_integration_spec.rb @@ -157,6 +157,8 @@ it 'copies the custom_field' do expect(subject).to be_success + expect(project_copy.project_custom_fields).to contain_exactly(user_custom_field) + cv = project_copy.custom_values.reload.find_by(custom_field: user_custom_field) expect(cv).to be_present expect(cv.value).to eq user_value.id.to_s @@ -177,6 +179,8 @@ it 'copies the custom_field' do expect(subject).to be_success + expect(project_copy.project_custom_fields).to contain_exactly(list_custom_field) + cv = project_copy.custom_values.reload.where(custom_field: list_custom_field).to_a expect(cv).to be_a Array expect(cv.count).to eq 2 @@ -184,6 +188,17 @@ end end + context 'with disabled project custom fields with default value' do + it 'is still disabled in the copy' do + create(:text_project_custom_field, default_value: 'default value') + + expect(subject).to be_success + + expect(source.project_custom_fields).to eq([]) + expect(project_copy.project_custom_fields).to match_array(source.project_custom_fields) + end + end + context 'with disabled work package custom field' do it 'is still disabled in the copy' do custom_field = create(:text_wp_custom_field) From c2480a1601560cac18ef8d16b97429984636bcf4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 7 Mar 2024 22:57:35 +0700 Subject: [PATCH 138/218] added specs for invisible custom fields for project creation --- spec/features/projects/create_spec.rb | 83 +++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index 0d1199b6baaa..286077a6c56f 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -156,7 +156,6 @@ let!(:unused_custom_field) do create(:project_custom_field, name: 'Unused Foo', field_format: 'string', - is_for_all: true, project_custom_field_section:) end @@ -189,7 +188,6 @@ create(:project_custom_field, name: 'Foo with default value', field_format: 'string', default_value: 'Default value', - is_for_all: true, project_custom_field_section:) end @@ -257,14 +255,19 @@ create(:project_custom_field, name: 'Boolean with default true', field_format: 'bool', default_value: true, - is_for_all: true, + project_custom_field_section:) + end + + let!(:custom_boolean_field_default_false) do + create(:project_custom_field, name: 'Boolean with default false', + field_format: 'bool', + default_value: false, project_custom_field_section:) end let!(:custom_boolean_field_with_no_default) do create(:project_custom_field, name: 'Boolean with no default', field_format: 'bool', - is_for_all: true, project_custom_field_section:) end @@ -284,12 +287,14 @@ project = Project.last - # custom_field_with_default_value should be activated and contain the overwritten value expect(project.project_custom_field_ids).to contain_exactly( - required_custom_field.id, custom_boolean_field_default_true.id + required_custom_field.id, + custom_boolean_field_default_true.id, + custom_boolean_field_default_false.id ) expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_truthy + expect(project.custom_value_for(custom_boolean_field_default_false).typed_value).to be_falsy end it 'enables boolean custom fields without default values if set to true explicitly' do @@ -301,12 +306,13 @@ project = Project.last - # custom_field_with_default_value should be activated and contain the overwritten value expect(project.project_custom_field_ids).to contain_exactly( - required_custom_field.id, custom_boolean_field_default_true.id, custom_boolean_field_with_no_default.id + required_custom_field.id, + custom_boolean_field_default_true.id, + custom_boolean_field_default_false.id, + custom_boolean_field_with_no_default.id ) - expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_truthy expect(project.custom_value_for(custom_boolean_field_with_no_default).typed_value).to be_truthy end @@ -319,14 +325,69 @@ project = Project.last - # custom_field_with_default_value should be activated and contain the overwritten value expect(project.project_custom_field_ids).to contain_exactly( - required_custom_field.id, custom_boolean_field_default_true.id + required_custom_field.id, + custom_boolean_field_default_true.id, + custom_boolean_field_default_false.id ) expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_falsy end end end + + context 'with correct handling of invisible values' do + let!(:invisible_field) do + create(:string_project_custom_field, name: 'Text for Admins only', + visible: false, + project_custom_field_section:) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + context 'with an admin user' do + it 'shows invisible fields in the form and allows their activation' do + expect(page).to have_content 'Text for Admins only' + + fill_in 'Text for Admins only', with: 'foo' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, invisible_field.id + ) + + expect(project.custom_value_for(invisible_field).typed_value).to eq('foo') + end + end + + context 'with a non-admin user' do + current_user { create(:user, global_permissions: %i[add_project]) } + + it 'does not show invisible fields in the form and thus not activates the invisible field' do + expect(page).to have_no_content 'Text for Admins only' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id + ) + end + end + end end end From a00a44a3c7b1efac8c8815bf7403d45b31eed7b6 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 8 Mar 2024 10:47:03 +0700 Subject: [PATCH 139/218] fixed and finetuned project custom field activation for create, update and copy contexts --- app/models/custom_value.rb | 4 +- .../projects/acts_as_customizable_patches.rb | 5 +- app/services/projects/copy_service.rb | 27 ++++++-- spec/features/projects/copy_spec.rb | 23 ++++--- spec/models/projects/customizable_spec.rb | 62 ++++++++++++++++++- 5 files changed, 101 insertions(+), 20 deletions(-) diff --git a/app/models/custom_value.rb b/app/models/custom_value.rb index eeee77edd8b2..ea8fde497060 100644 --- a/app/models/custom_value.rb +++ b/app/models/custom_value.rb @@ -35,7 +35,7 @@ class CustomValue < ApplicationRecord validate :validate_type_of_value validate :validate_length_of_value - after_create :activate_custom_field_in_customized_project, if: -> { customized.is_a?(Project) && value.present? } + after_create :activate_custom_field_in_customized_project, if: -> { customized.is_a?(Project) } delegate :typed_value, :formatted_value, @@ -69,6 +69,8 @@ def default? end def activate_custom_field_in_customized_project + return if default? || value.blank? + # if a custom value is created for a project via CustomValue.create(...), # the custom field needs to be activated in the project unless customized&.project_custom_fields&.include?(custom_field) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 41fbda4b9c89..76d4f4bcdbe9 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -39,7 +39,7 @@ module Projects::ActsAsCustomizablePatches dependent: :destroy, inverse_of: :project has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' - before_save :build_missing_project_custom_field_project_mappings + before_create :build_missing_project_custom_field_project_mappings after_save :reset_section_scoped_validation, :set_query_available_custom_fields_to_project_level # we need to reset the query_available_custom_fields_on_global_level already after validation @@ -57,8 +57,7 @@ def build_missing_project_custom_field_project_mappings # current shortcommings: # - boolean custom fields are always activated as a nil value is never provided (always true/false) custom_field_ids = project.custom_values - .reject { |cv| cv.value.blank? } - .reject { |cv| cv.persisted? } # do not reactivate custom fields which have already been used and disabled + .select { |cv| cv.value.present? } .pluck(:custom_field_id).uniq activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id).uniq diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb index 02199b83b0c5..48c0cefc94c3 100644 --- a/app/services/projects/copy_service.rb +++ b/app/services/projects/copy_service.rb @@ -81,24 +81,39 @@ def before_perform(params, service_call) end def after_perform(call) - remove_custom_fields_not_activated_in_source(call) + disable_custom_fields_not_activated_in_source(call) + enable_custom_fields_not_activated_in_target(call) super end - def remove_custom_fields_not_activated_in_source(call) + def disable_custom_fields_not_activated_in_source(call) # TODO: seems a bit too hacky to me, find better solution # remove custom fields from target project which are not activated in source project # this is required in cases, when # a custom field with a default value exists but is not activated in the source project # this custom field is then not shown in the copy form (which is desired) - # but the custom field would be activated in the target project with its default value (which is hard to prevent) - # thus we clean them up here: + # but the custom field would be activated in the target project with its default value, + # which is desired in pure project creation context + # but in the copy context we clean them up here: custom_fields_activated_in_source = source.project_custom_fields.pluck(:id) custom_fields_activated_in_target = call.result.project_custom_fields.pluck(:id) - custom_fields_to_remove = custom_fields_activated_in_target - custom_fields_activated_in_source - call.result.project_custom_fields.where(id: custom_fields_to_remove).destroy_all + custom_fields_to_disable = custom_fields_activated_in_target - custom_fields_activated_in_source + call.result.project_custom_field_project_mappings.where(custom_field_id: custom_fields_to_disable).destroy_all + end + + def enable_custom_fields_not_activated_in_target(call) + # TODO: seems a bit too hacky to me, find better solution + # if custom fields in source project are activated but set to blank + # they would not be activated in the target project, + # which is desired in pure project creation context (-> as form fields are blank) + # but in the copy context we activate them here in order to have the same mapping as seen in the source project: + custom_fields_activated_in_source = source.project_custom_fields.pluck(:id) + custom_fields_activated_in_target = call.result.project_custom_fields.pluck(:id) + + custom_fields_to_enable = custom_fields_activated_in_source - custom_fields_activated_in_target + call.result.project_custom_fields << ProjectCustomField.where(id: custom_fields_to_enable) end def contract_options diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index db69f6c59ed0..7ba1583efbfc 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -184,7 +184,7 @@ ) end - it 'disables optional project custom fields if explicitly set to blank' do + it 'does not disable optional project custom fields if explicitly set to blank' do # Expand advanced settings click_on 'Advanced settings' @@ -199,8 +199,12 @@ expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, + optional_project_custom_field.id, optional_project_custom_field_with_default.id ) + + # the optional custom field is activated, but set to blank value + expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq('') end # TBD: Is this intended from a conceptial point of view? @@ -208,7 +212,7 @@ # If not, I don't know how to change this behavior while keeping the behavior specified in the creation spec where # optional custom fields are not activated if the value is set to blank in the form (which seems to be desired from # a concpetional point of view) - it 'does not enable project custom fields (with default values) if set to blank in source project' do + it 'does enable project custom fields if set to blank in source project' do project.update!(custom_field_values: { optional_project_custom_field.id => '', optional_project_custom_field_with_default.id => '' @@ -237,8 +241,14 @@ copied_project = Project.find_by(name: 'Copied project') expect(copied_project.project_custom_field_ids).to contain_exactly( - project_custom_field.id + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id ) + + # the optional custom fields are activated, but set to blank values as seen in source project + expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq('') + expect(copied_project.custom_value_for(optional_project_custom_field_with_default).typed_value).to eq('') end context 'with project custom fields with default values, which are disabled in source project' do @@ -326,7 +336,7 @@ context 'with non-admin user' do # TBD: Not sure if this is the desired behavior, but would be a bit tricky to change - it 'does not show invisible fields in the form and thus do not activate them' do + it 'does not show invisible fields in the form and but still activates them' do expect(page).to have_no_content 'Text for Admins only' click_button 'Save' @@ -338,10 +348,9 @@ expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, optional_project_custom_field.id, - optional_project_custom_field_with_default.id + optional_project_custom_field_with_default.id, + invisible_field.id ) - - expect(copied_project.custom_value_for(invisible_field)).to be_nil end end end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index b33719040f11..a215180f7191 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -232,10 +232,37 @@ expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) end end + + context 'with correct handling of custom fields with default values' do + let!(:text_custom_field_with_default) do + create(:text_project_custom_field, + default_value: 'default', + project_custom_field_section: section) + end + + it 'activates custom fields with default values if not explicitly set to blank' do + project = create(:project, custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true + }) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id, text_custom_field_with_default.id) + end + + it 'does not activate custom fields with default values if explicitly set to blank' do + project = create(:project, custom_field_values: { + text_custom_field.id => 'foo', + bool_custom_field.id => true, + text_custom_field_with_default.id => '' + }) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + end + end end context 'when updating with custom field values' do - let(:project) { create(:project) } + let!(:project) { create(:project) } shared_examples 'implicitly enabled and saved custom values' do it 'enables fields with provided values' do @@ -311,9 +338,38 @@ .to contain_exactly(bool_custom_field) end - it 'does re-enable fields with new value which have been disabled in the past' do - pending "this is currently not working, not sure if it should be supported at all" + context 'with correct handling of custom fields with default values' do + let!(:text_custom_field_with_default) do + create(:text_project_custom_field, + default_value: 'default', + project_custom_field_section: section) + end + + it 'does not activate custom fields with default values if not explicitly set to a value' do + project.update!(custom_field_values: { + text_custom_field.id => 'bar', + bool_custom_field.id => false + }) + + # text_custom_field_with_default is not provided, thus it should not be enabled (in contrast to creation) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + end + it 'does activate custom fields with default values if explicitly set to a value' do + project.update!(custom_field_values: { + text_custom_field.id => 'bar', + bool_custom_field.id => false, + text_custom_field_with_default.id => 'overwritten default' + }) + + # text_custom_field_with_default is not provided, thus it should not be enabled (in contrast to creation) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id, text_custom_field_with_default.id) + end + end + + it 'does re-enable fields with new value which have been disabled in the past' do project.update!(custom_field_values: { text_custom_field.id => 'foo', bool_custom_field.id => true From a91d0ef63122e5ca5b33e2a2770fdd15dcee2ffd Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 8 Mar 2024 10:54:52 +0700 Subject: [PATCH 140/218] adjusted custom value specs --- spec/models/custom_value_spec.rb | 63 +++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/spec/models/custom_value_spec.rb b/spec/models/custom_value_spec.rb index a2959f03467a..47966bc810ff 100644 --- a/spec/models/custom_value_spec.rb +++ b/spec/models/custom_value_spec.rb @@ -735,26 +735,63 @@ end describe '#activate_custom_field_in_customized_project' do - let(:custom_field) { create(:project_custom_field) } let(:project) { create(:project) } - context 'when a value is set' do - let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "foo") } + context 'with a given default value' do + let(:custom_field) { create(:string_project_custom_field, default_value: "foo") } - it 'activates the custom field in the project after create if missing' do - expect(project.project_custom_fields).not_to include(custom_field) - custom_value.save! - expect(project.reload.project_custom_fields).to include(custom_field) + context 'when a value other than the default value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "bar") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).to include(custom_field) + end + end + + context 'when a value equal to the default value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "foo") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end + end + + context 'when a value is not set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + + it 'does not activate the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end end end - context 'when a value is not set' do - let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + context 'with no default value given' do + let(:custom_field) { create(:string_project_custom_field) } + + context 'when a value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "bar") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).to include(custom_field) + end + end - it 'does not activate the custom field in the project after create if missing' do - expect(project.project_custom_fields).not_to include(custom_field) - custom_value.save! - expect(project.reload.project_custom_fields).not_to include(custom_field) + context 'when a value is not set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + + it 'does not activate the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end end end end From b870ab9a2b4e9d492abc38d9f34d9b26a7c1e1a9 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 12 Mar 2024 11:53:47 +0700 Subject: [PATCH 141/218] Added uniqueness validation as suggested by @dombesz --- .../project_custom_field_project_mapping.rb | 12 +---- ...oject_custom_field_project_mapping_spec.rb | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 spec/models/project_custom_field_project_mapping_spec.rb diff --git a/app/models/project_custom_field_project_mapping.rb b/app/models/project_custom_field_project_mapping.rb index faff27f27c63..217e06eadd42 100644 --- a/app/models/project_custom_field_project_mapping.rb +++ b/app/models/project_custom_field_project_mapping.rb @@ -31,15 +31,5 @@ class ProjectCustomFieldProjectMapping < ApplicationRecord belongs_to :project_custom_field, class_name: 'ProjectCustomField', foreign_key: 'custom_field_id', inverse_of: :project_custom_field_project_mappings - # # Additionally to the database-level unique constraint, the application-level validation ensures that a - # # custom_field is associated with only one section - # validate :project_custom_field_uniqueness - - # private - - # def project_custom_field_uniqueness - # if ProjectCustomFieldSectionMapping.where(custom_field_id:).where.not(id:).exists? - # errors.add(:project_custom_field, "is already associated with another section") - # end - # end + validates :custom_field_id, uniqueness: { scope: :project_id } end diff --git a/spec/models/project_custom_field_project_mapping_spec.rb b/spec/models/project_custom_field_project_mapping_spec.rb new file mode 100644 index 000000000000..f7baa6fc572d --- /dev/null +++ b/spec/models/project_custom_field_project_mapping_spec.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldProjectMapping do + describe 'uniqueness by project' do + let!(:project) { create(:project) } + let!(:project_custom_field) { create(:project_custom_field) } + + it 'a project custom field can only be mapped to a project once' do + project.project_custom_fields << project_custom_field + + expect(described_class).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + + expect do + project.project_custom_fields << project_custom_field + end.to raise_error(ActiveRecord::RecordInvalid, /Custom field has already been taken/) + end + end +end From 30e84629bf0359468198662f1d9d83f217ce7e48 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 12 Mar 2024 13:56:01 +0700 Subject: [PATCH 142/218] Cleanup as suggested by @dombesz --- .../project_custom_fields/edit_form_header_component.rb | 3 --- .../settings/project_custom_fields/header_component.rb | 4 ---- .../project_custom_fields/new_form_header_component.rb | 6 ------ 3 files changed, 13 deletions(-) diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.rb b/app/components/settings/project_custom_fields/edit_form_header_component.rb index 2e6450b489a5..932bd6d5c121 100644 --- a/app/components/settings/project_custom_fields/edit_form_header_component.rb +++ b/app/components/settings/project_custom_fields/edit_form_header_component.rb @@ -29,9 +29,6 @@ module Settings module ProjectCustomFields class EditFormHeaderComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - def initialize(custom_field:) super diff --git a/app/components/settings/project_custom_fields/header_component.rb b/app/components/settings/project_custom_fields/header_component.rb index 74477d49f406..5970978e5329 100644 --- a/app/components/settings/project_custom_fields/header_component.rb +++ b/app/components/settings/project_custom_fields/header_component.rb @@ -32,10 +32,6 @@ class HeaderComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable - - def initialize - super - end end end end diff --git a/app/components/settings/project_custom_fields/new_form_header_component.rb b/app/components/settings/project_custom_fields/new_form_header_component.rb index 1a138fb0e269..8c9f07076f8e 100644 --- a/app/components/settings/project_custom_fields/new_form_header_component.rb +++ b/app/components/settings/project_custom_fields/new_form_header_component.rb @@ -29,12 +29,6 @@ module Settings module ProjectCustomFields class NewFormHeaderComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - - def initialize - super - end end end end From 0abd390ff4b491169133cb95b489821e3b688d91 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 12 Mar 2024 14:08:23 +0700 Subject: [PATCH 143/218] Minor cleanups --- app/models/projects/acts_as_customizable_patches.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index 76d4f4bcdbe9..cde7c21725fd 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -39,9 +39,6 @@ module Projects::ActsAsCustomizablePatches dependent: :destroy, inverse_of: :project has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' - before_create :build_missing_project_custom_field_project_mappings - after_save :reset_section_scoped_validation, :set_query_available_custom_fields_to_project_level - # we need to reset the query_available_custom_fields_on_global_level already after validation # as the update service just calls .valid? and returns if invalid # after_save is not touched in this case which causes the flag to stay active @@ -50,12 +47,13 @@ module Projects::ActsAsCustomizablePatches before_update :set_query_available_custom_fields_to_global_level before_create :reject_section_scoped_validation_for_creation + before_create :build_missing_project_custom_field_project_mappings + after_create :disable_custom_fields_with_empty_values + after_save :reset_section_scoped_validation, :set_query_available_custom_fields_to_project_level def build_missing_project_custom_field_project_mappings # activate custom fields for this project (via mapping table) if values have been provided for custom_fields but no mapping exists - # current shortcommings: - # - boolean custom fields are always activated as a nil value is never provided (always true/false) custom_field_ids = project.custom_values .select { |cv| cv.value.present? } .pluck(:custom_field_id).uniq From ee291e22f5a0ceb9c185a520f52ebeb9afee4416 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 12 Mar 2024 14:10:39 +0700 Subject: [PATCH 144/218] Rename service as suggested by @dombesz --- .../projects/settings/project_custom_fields_controller.rb | 2 +- .../{bulk_edit_service.rb => bulk_update_service.rb} | 2 +- .../{bulk_edit_service_spec.rb => bulk_update_service_spec.rb} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename app/services/project_custom_field_project_mappings/{bulk_edit_service.rb => bulk_update_service.rb} (98%) rename spec/services/project_custom_field_project_mappings/{bulk_edit_service_spec.rb => bulk_update_service_spec.rb} (98%) diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 441eb7694107..a95e86c5dd2c 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -109,7 +109,7 @@ def set_project_custom_field_section end def bulk_edit_service - ProjectCustomFieldProjectMappings::BulkEditService + ProjectCustomFieldProjectMappings::BulkUpdateService .new( user: current_user, project: @project, diff --git a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb b/app/services/project_custom_field_project_mappings/bulk_update_service.rb similarity index 98% rename from app/services/project_custom_field_project_mappings/bulk_edit_service.rb rename to app/services/project_custom_field_project_mappings/bulk_update_service.rb index 35e2d195483d..8dd4b69f0441 100644 --- a/app/services/project_custom_field_project_mappings/bulk_edit_service.rb +++ b/app/services/project_custom_field_project_mappings/bulk_update_service.rb @@ -27,7 +27,7 @@ #++ module ProjectCustomFieldProjectMappings - class BulkEditService < ::BaseServices::BaseCallable + class BulkUpdateService < ::BaseServices::BaseCallable def initialize(user:, project:, project_custom_field_section:) super() @user = user diff --git a/spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb b/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb similarity index 98% rename from spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb rename to spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb index b525697144ee..799403ac4498 100644 --- a/spec/services/project_custom_field_project_mappings/bulk_edit_service_spec.rb +++ b/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb @@ -28,7 +28,7 @@ require 'spec_helper' -RSpec.describe ProjectCustomFieldProjectMappings::BulkEditService do +RSpec.describe ProjectCustomFieldProjectMappings::BulkUpdateService do let!(:project) { create(:project) } let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } From e89446ea1a1888c1ab223915bcf5e9c7010522b5 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 12 Mar 2024 14:30:45 +0700 Subject: [PATCH 145/218] Refactored controller as suggested by @dombesz --- .../settings/project_custom_fields_controller.rb | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index a95e86c5dd2c..07dc498dfaaa 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -32,10 +32,7 @@ class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsCont menu_item :settings_project_custom_fields - before_action :eager_load_project_custom_field_sections, only: %i[show toggle enable_all_of_section disable_all_of_section] - before_action :eager_load_project_custom_fields, only: %i[show toggle enable_all_of_section disable_all_of_section] - before_action :eager_load_project_custom_field_project_mappings, - only: %i[show toggle enable_all_of_section disable_all_of_section] + before_action :eager_load_project_custom_field_data, only: %i[show toggle enable_all_of_section disable_all_of_section] before_action :set_project_custom_field_section, only: %i[enable_all_of_section disable_all_of_section] @@ -58,7 +55,7 @@ def enable_all_of_section call = bulk_edit_service.call(action: :enable) if call.success? - eager_load_project_custom_field_project_mappings # reload mappings + eager_load_project_custom_field_data # reload mappings update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references else @@ -72,7 +69,7 @@ def disable_all_of_section call = bulk_edit_service.call(action: :disable) if call.success? - eager_load_project_custom_field_project_mappings # reload mappings + eager_load_project_custom_field_data # reload mappings update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references else @@ -84,19 +81,15 @@ def disable_all_of_section private - def eager_load_project_custom_field_sections + def eager_load_project_custom_field_data @project_custom_field_sections = ProjectCustomFieldSection.all.to_a - end - def eager_load_project_custom_fields @project_custom_fields_grouped_by_section = ProjectCustomField .visible .includes(:project_custom_field_section) .sort_by { |pcf| pcf.project_custom_field_section.position } .group_by(&:custom_field_section_id) - end - def eager_load_project_custom_field_project_mappings @project_custom_field_project_mappings = ProjectCustomFieldProjectMapping .where(project_id: @project.id) .to_a From 442eb03cdc6d13e945ecf6c83dbf338ffdb22480 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:19:27 +0100 Subject: [PATCH 146/218] Refactoring as suggested by @dombesz Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- app/controllers/custom_fields_controller.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index 9a34b25614f5..b35ed052dd1a 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -38,9 +38,7 @@ class CustomFieldsController < ApplicationController def index # loading wp cfs exclicity to allow for eager loading @custom_fields_by_type = CustomField.all - .where.not(type: 'WorkPackageCustomField') - # ProjectCustomFields now managed in a different UI - .tap { |query| query.where.not(type: 'ProjectCustomField') } + .where.not(type: ['WorkPackageCustomField', 'ProjectCustomField']) .group_by { |f| f.class.name } @custom_fields_by_type['WorkPackageCustomField'] = WorkPackageCustomField.includes(:types).all From 77094a5ff337e7c14cc3aae4fcf7975e90b9067b Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 12 Mar 2024 16:55:28 +0700 Subject: [PATCH 147/218] Cleanup as suggested by @dombesz --- config/initializers/feature_decisions.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 49564661244f..407769fe3cd4 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -38,5 +38,3 @@ # initializer 'the_engine.feature_decisions' do # OpenProject::FeatureDecisions.add :some_flag # end - -OpenProject::FeatureDecisions.add :project_attributes From ffa99e492967fa754c525dcffb648f876ef4f3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 12 Mar 2024 13:19:35 +0100 Subject: [PATCH 148/218] Fix trackBy function receiving item as first argument --- .../op-autocompleter/op-autocompleter.component.ts | 2 +- .../user-autocompleter/user-autocompleter.component.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts index 2973248a39f4..9ec330c83e51 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts @@ -545,7 +545,7 @@ export class OpAutocompleterComponent unknown)|null { + protected defaultTrackByFunction():((x:unknown) => unknown)|null { return null; } diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index f9c018f7fe83..8cb2923d9c1a 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -44,7 +44,7 @@ import { UserAutocompleterTemplateComponent, } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component'; import { IUser } from 'core-app/core/state/principals/user.model'; -import { compareByAttribute, trackByProperty } from 'core-app/shared/helpers/angular/tracking-functions'; +import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions'; export const usersAutocompleterSelector = 'op-user-autocompleter'; @@ -136,8 +136,8 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent unknown|null { - return trackByProperty('href'); + protected defaultTrackByFunction():(item:{ href:unknown }) => unknown|null { + return (item) => item.href; } protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { From 098654dd3801cad39f8abd7f5dc4533710c20465 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 6 Mar 2024 15:37:25 +0200 Subject: [PATCH 149/218] Update project details widget deprecation message. --- .../widgets/project-details/project-details.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html index a37c74d9be50..6474c838dde0 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html @@ -13,7 +13,7 @@ Project details have now moved to a column on the right edge of this page.

- Starting with version 13.4, project attributes can be grouped in sections and enabled and disabled at a project level. Learn more + Starting with version 13.5, project attributes can be grouped in sections and enabled and disabled at a project level. Learn more

This widget can now be removed or replaced. It will be deleted in subsequent versions.
From 3b0c995fa5516cf0f313cf1494a1a8f11a311530 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 12 Mar 2024 14:38:51 +0200 Subject: [PATCH 150/218] Respect closed version setting in multi select version fields --- .../inputs/multi_version_select_list.rb | 6 ++- .../overview_page/dialog/inputs_spec.rb | 48 ++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/app/forms/custom_fields/inputs/multi_version_select_list.rb b/app/forms/custom_fields/inputs/multi_version_select_list.rb index d0c3dd82ef6d..8092247ccb89 100644 --- a/app/forms/custom_fields/inputs/multi_version_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_version_select_list.rb @@ -27,6 +27,10 @@ #++ class CustomFields::Inputs::MultiVersionSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput + include AssignableCustomFieldValues + + delegate :assignable_versions, to: :@object + form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field @@ -38,7 +42,7 @@ class CustomFields::Inputs::MultiVersionSelectList < CustomFields::Inputs::Base: )) custom_value_form.autocompleter(**input_attributes) do |list| - @object.versions.each do |version| + assignable_custom_field_values(@custom_field).each do |version| list.option( label: version.name, value: version.id, selected: selected?(version) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb index 17efbefbeb45..b95abbc8d325 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb @@ -525,17 +525,51 @@ it_behaves_like 'a autocomplete multi select field' describe 'with correct version scoping' do - let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) + context "with a version on a different project" do + let!(:version_in_other_project) do + create(:version, name: "Version 1 in other project", project: other_project) + end + + it "shows only versions that are associated with this project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Version 1") + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end end - it 'shows only versions that are associated with this project' do - overview_page.open_edit_dialog_for_section(section) + context "with a closed version" do + let!(:closed_version) { create(:version, name: "Closed version", project:, status: "closed") } + + before do + custom_field.update(allow_non_open_versions:) + end + + context "when non-open versions are not allowed" do + let(:allow_non_open_versions) { false } - field.search('Version 1') + it "does not shows closed version option" do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_no_option(closed_version.name) + end + end + + context "when non-open versions are allowed" do + let(:allow_non_open_versions) { true } + + it "shows closed version option" do + overview_page.open_edit_dialog_for_section(section) + field.open_options - field.expect_option(first_version.name) - field.expect_no_option(version_in_other_project.name) + field.expect_option(first_version.name) + field.expect_option(closed_version.name) + end + end end end end From 7693bd25bc548989b13f8be508fc94a8cb4309a1 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 13 Mar 2024 12:02:42 +0700 Subject: [PATCH 151/218] Refactored prototypical implementation as suggested by @dombesz --- .../project_custom_fields_controller.rb | 52 +++++------ .../project_custom_fields/drop_service.rb | 93 +++++++++++++++++++ 2 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 app/services/project_custom_fields/drop_service.rb diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index e5b12f7f5d70..9dcc5169df0d 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -40,10 +40,10 @@ class ProjectCustomFieldsController < ::Admin::SettingsController before_action :find_custom_option, only: :delete_option def default_breadcrumb - if action_name == 'index' - t('label_project_attribute_plural') + if action_name == "index" + t("label_project_attribute_plural") else - ActionController::Base.helpers.link_to(t('label_project_attribute_plural'), admin_settings_project_custom_fields_path) + ActionController::Base.helpers.link_to(t("label_project_attribute_plural"), admin_settings_project_custom_fields_path) end end @@ -70,36 +70,29 @@ def new def edit; end def move - # prototyopical implementation - # needs refactoring via update service - @custom_field.move_to = params[:move_to]&.to_sym + call = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( + move_to: params[:move_to]&.to_sym + ) - update_sections_via_turbo_stream(project_custom_field_sections: @project_custom_field_sections) + if call.success? + update_sections_via_turbo_stream(project_custom_field_sections: @project_custom_field_sections) + else + # TODO: handle error + end respond_with_turbo_streams end def drop - # prototyopical implementation - # needs refactoring via update service - current_section = @custom_field.project_custom_field_section - current_section_id = current_section.id - new_section_id = params[:target_id].to_i - - if current_section_id != new_section_id - section_changed = true - old_section = current_section - current_section = ProjectCustomFieldSection.find(params[:target_id].to_i) - @custom_field.remove_from_list - @custom_field.update(project_custom_field_section: current_section) - end - - @custom_field.insert_at(params[:position].to_i) - - update_section_via_turbo_stream(project_custom_field_section: current_section) + call = ::ProjectCustomFields::DropService.new(user: current_user, project_custom_field: @custom_field).call( + target_id: params[:target_id], + position: params[:position] + ) - if section_changed - update_section_via_turbo_stream(project_custom_field_section: old_section) + if call.success? + drop_success_streams(call) + else + # TODO: handle error end respond_with_turbo_streams @@ -126,5 +119,12 @@ def find_custom_field rescue ActiveRecord::RecordNotFound render_404 end + + def drop_success_streams(call) + update_section_via_turbo_stream(project_custom_field_section: call.result[:current_section]) + if call.result[:section_changed] + update_section_via_turbo_stream(project_custom_field_section: call.result[:old_section]) + end + end end end diff --git a/app/services/project_custom_fields/drop_service.rb b/app/services/project_custom_fields/drop_service.rb new file mode 100644 index 000000000000..dfd91263c6b4 --- /dev/null +++ b/app/services/project_custom_fields/drop_service.rb @@ -0,0 +1,93 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFields + class DropService < ::BaseServices::BaseCallable + def initialize(user:, project_custom_field:) + super() + @user = user + @project_custom_field = project_custom_field + end + + def perform(params) + service_call = validate_permissions + service_call = perform_drop(service_call, params) if service_call.success? + + service_call + end + + def validate_permissions + if @user.admin? + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def perform_drop(service_call, params) + begin + section_changed, current_section, old_section = check_and_update_section_if_changed(params) + update_position(params[:position]&.to_i) + + service_call.success = true + service_call.result = { section_changed:, current_section:, old_section: } + rescue StandardError => e + service_call.success = false + service_call.errors = e.message + end + + service_call + end + + private + + def check_and_update_section_if_changed(params) + current_section = @project_custom_field.project_custom_field_section + new_section_id = params[:target_id]&.to_i + + if current_section.id != new_section_id + old_section = current_section + current_section = update_section(new_section_id) + return [true, current_section, old_section] + end + + [false, current_section, nil] + end + + def update_section(new_section_id) + current_section = ProjectCustomFieldSection.find(new_section_id) + @project_custom_field.remove_from_list + @project_custom_field.update(project_custom_field_section: current_section) + current_section + end + + def update_position(new_position) + @project_custom_field.insert_at(new_position) + end + end +end From b8a07a95e25d7d89ee47ac88d5c2faefe4ea9a64 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 13 Mar 2024 16:55:40 +0700 Subject: [PATCH 152/218] Refactored to a service based approach as suggested by @dombesz --- .../base_contract.rb | 37 +++++++++++++++ .../create_contract.rb | 32 +++++++++++++ .../delete_contract.rb | 39 +++++++++++++++ .../update_contract.rb | 32 +++++++++++++ ...roject_custom_field_sections_controller.rb | 47 ++++++++++++------- .../create_service.rb | 32 +++++++++++++ .../delete_service.rb | 32 +++++++++++++ .../set_attributes_service.rb | 32 +++++++++++++ .../update_service.rb | 32 +++++++++++++ 9 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 app/contracts/project_custom_field_sections/base_contract.rb create mode 100644 app/contracts/project_custom_field_sections/create_contract.rb create mode 100644 app/contracts/project_custom_field_sections/delete_contract.rb create mode 100644 app/contracts/project_custom_field_sections/update_contract.rb create mode 100644 app/services/project_custom_field_sections/create_service.rb create mode 100644 app/services/project_custom_field_sections/delete_service.rb create mode 100644 app/services/project_custom_field_sections/set_attributes_service.rb create mode 100644 app/services/project_custom_field_sections/update_service.rb diff --git a/app/contracts/project_custom_field_sections/base_contract.rb b/app/contracts/project_custom_field_sections/base_contract.rb new file mode 100644 index 000000000000..951c64d1e39e --- /dev/null +++ b/app/contracts/project_custom_field_sections/base_contract.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :name + attribute :position + attribute :type + end +end diff --git a/app/contracts/project_custom_field_sections/create_contract.rb b/app/contracts/project_custom_field_sections/create_contract.rb new file mode 100644 index 000000000000..91e44c3aaec1 --- /dev/null +++ b/app/contracts/project_custom_field_sections/create_contract.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class CreateContract < BaseContract + end +end diff --git a/app/contracts/project_custom_field_sections/delete_contract.rb b/app/contracts/project_custom_field_sections/delete_contract.rb new file mode 100644 index 000000000000..3b5e38d49308 --- /dev/null +++ b/app/contracts/project_custom_field_sections/delete_contract.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class DeleteContract < BaseContract + validate :section_not_in_use + + def section_not_in_use + if model.custom_fields.exists? + errors.add(:base, :in_use) + end + end + end +end diff --git a/app/contracts/project_custom_field_sections/update_contract.rb b/app/contracts/project_custom_field_sections/update_contract.rb new file mode 100644 index 000000000000..85960355c764 --- /dev/null +++ b/app/contracts/project_custom_field_sections/update_contract.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class UpdateContract < BaseContract + end +end diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb index 041749c9e0a7..e861d0b475cc 100644 --- a/app/controllers/admin/settings/project_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -31,61 +31,74 @@ class ProjectCustomFieldSectionsController < ::Admin::SettingsController include OpTurbo::ComponentStream include Admin::Settings::ProjectCustomFields::ComponentStreams - before_action :set_project_custom_field_section, only: %i[move drop] + before_action :set_project_custom_field_section, only: %i[update move drop destroy] def create - @project_custom_field_section = ProjectCustomFieldSection.new( - name: project_custom_field_section_params[:name], - position: 1 # show new sections at the top of the list, otherwise might not be visible to user + # show new sections at the top of the list, otherwise might not be visible to user + call = ::ProjectCustomFieldSections::CreateService.new(user: current_user).call( + project_custom_field_section_params.merge(position: 1) ) - if @project_custom_field_section.save + if call.success? update_header_via_turbo_stream # required to closed the dialog update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: @project_custom_field_section) + update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) end respond_with_turbo_streams end def update - @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) + call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call( + project_custom_field_section_params + ) - if @project_custom_field_section.update(project_custom_field_section_params) - update_section_via_turbo_stream(project_custom_field_section: @project_custom_field_section) + if call.success? + update_section_via_turbo_stream(project_custom_field_section: call.result) else - update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: @project_custom_field_section) + update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) end respond_with_turbo_streams end def destroy - @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) + call = ::ProjectCustomFieldSections::DeleteService.new(user: current_user, model: @project_custom_field_section).call - if @project_custom_field_section.destroy + if call.success? update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + # TODO: show error message end respond_with_turbo_streams end def move - @project_custom_field_section.move_to = params[:move_to]&.to_sym + call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call( + move_to: params[:move_to]&.to_sym + ) - if @project_custom_field_section.save + if call.success? update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + # TODO: show error message end respond_with_turbo_streams end def drop - @project_custom_field_section.insert_at(params[:position].to_i) - - update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call( + position: params[:position].to_i + ) + if call.success? + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + # TODO: show error message + end respond_with_turbo_streams end diff --git a/app/services/project_custom_field_sections/create_service.rb b/app/services/project_custom_field_sections/create_service.rb new file mode 100644 index 000000000000..01cf2b20db79 --- /dev/null +++ b/app/services/project_custom_field_sections/create_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/project_custom_field_sections/delete_service.rb b/app/services/project_custom_field_sections/delete_service.rb new file mode 100644 index 000000000000..823c96e8c41e --- /dev/null +++ b/app/services/project_custom_field_sections/delete_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/project_custom_field_sections/set_attributes_service.rb b/app/services/project_custom_field_sections/set_attributes_service.rb new file mode 100644 index 000000000000..db7da29c9cd5 --- /dev/null +++ b/app/services/project_custom_field_sections/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_custom_field_sections/update_service.rb b/app/services/project_custom_field_sections/update_service.rb new file mode 100644 index 000000000000..7611c6d4cd76 --- /dev/null +++ b/app/services/project_custom_field_sections/update_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ProjectCustomFieldSections + class UpdateService < ::BaseServices::Update + end +end From e93e39e19c2ef00f6837013bbc768cced4e9485f Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 14 Mar 2024 16:25:21 +0700 Subject: [PATCH 153/218] fixing last CI errors, mostly related to adjusted behaviour of the angular autocomplete input --- app/views/members/_member_form.html.erb | 2 +- .../spec/features/project_details_spec.rb | 44 +++--- .../lib/widget/filters/multi_choice.rb | 8 +- .../lib/widget/filters/multi_values.rb | 34 ++--- .../reporting/lib/widget/filters/project.rb | 14 +- spec/factories/custom_field_factory.rb | 66 ++++----- spec/features/projects/edit_settings_spec.rb | 134 +++++++++--------- 7 files changed, 152 insertions(+), 150 deletions(-) diff --git a/app/views/members/_member_form.html.erb b/app/views/members/_member_form.html.erb index 4831a7138396..dec20dd95623 100644 --- a/app/views/members/_member_form.html.erb +++ b/app/views/members/_member_form.html.erb @@ -59,7 +59,7 @@ See COPYRIGHT and LICENSE files for more details. <%= angular_component_tag 'opce-members-autocompleter', inputs: { - inputName: "member[user_ids][]", + inputName: "member[user_ids]", inputBindValue: "id", url: autocomplete_for_member_project_members_path + '.json', appendTo: "body", diff --git a/modules/dashboards/spec/features/project_details_spec.rb b/modules/dashboards/spec/features/project_details_spec.rb index 3957ba2c4cb7..828c04e60c89 100644 --- a/modules/dashboards/spec/features/project_details_spec.rb +++ b/modules/dashboards/spec/features/project_details_spec.rb @@ -26,12 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -require_relative '../support/pages/dashboard' +require_relative "../support/pages/dashboard" -RSpec.describe 'Project details widget on dashboard', :js do - let(:system_version) { create(:version, sharing: 'system') } +RSpec.describe "Project details widget on dashboard", :js do + let(:system_version) { create(:version, sharing: "system") } let!(:project) do create(:project, members: { other_user => role }) @@ -58,13 +58,13 @@ let(:editing_user) do create(:user, member_with_permissions: { project => editing_permissions }, - firstname: 'Cool', - lastname: 'Guy') + firstname: "Cool", + lastname: "Guy") end let(:other_user) do create(:user, - firstname: 'Other', - lastname: 'User') + firstname: "Other", + lastname: "User") end let(:dashboard_page) do @@ -75,7 +75,7 @@ def add_project_details_widget dashboard_page.visit! dashboard_page.add_widget(1, 1, :within, "Project details") - dashboard_page.expect_and_dismiss_toaster message: I18n.t('js.notice_successful_update') + dashboard_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") end before do @@ -83,19 +83,19 @@ def add_project_details_widget add_project_details_widget end - context 'without editing permissions' do + context "without editing permissions" do let(:current_user) { read_only_user } - it 'displays the deprecated message' do + it "displays the deprecated message" do # As the user lacks the manage_public_queries and save_queries permission, no other widget is present - details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') + details_widget = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(1)") within(details_widget.area) do expect(page) .to have_content("Project details have now moved to a column on the right edge of this page.") expect(page).to have_content( <<~TEXT.strip - Starting with version 13.4, project attributes can be grouped \ + Starting with version 13.5, project attributes can be grouped \ in sections and enabled and disabled at a project level. TEXT ) @@ -109,19 +109,19 @@ def add_project_details_widget end end - context 'with editing permissions' do + context "with editing permissions" do let(:current_user) { editing_user } - it 'displays the deprecated message' do + it "displays the deprecated message" do # As the user lacks the manage_public_queries and save_queries permission, no other widget is present - details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') + details_widget = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(1)") within(details_widget.area) do expect(page) .to have_content("Project details have now moved to a column on the right edge of this page.") expect(page).to have_content( <<~TEXT.strip - Starting with version 13.4, project attributes can be grouped \ + Starting with version 13.5, project attributes can be grouped \ in sections and enabled and disabled at a project level. TEXT ) @@ -135,17 +135,17 @@ def add_project_details_widget end end - context 'when project has Activity module enabled' do + context "when project has Activity module enabled" do let(:current_user) { read_only_user } it 'has a "Project activity" entry in More menu linking to the project activity page' do - details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') + details_widget = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(1)") - details_widget.expect_menu_item('Project details activity') + details_widget.expect_menu_item("Project details activity") - details_widget.click_menu_item('Project details activity') + details_widget.click_menu_item("Project details activity") expect(page).to have_current_path(project_activity_index_path(project), ignore_query: true) - expect(page).to have_checked_field(id: 'event_types_project_attributes') + expect(page).to have_checked_field(id: "event_types_project_attributes") end end end diff --git a/modules/reporting/lib/widget/filters/multi_choice.rb b/modules/reporting/lib/widget/filters/multi_choice.rb index fab1ed00456c..31a4080adbe0 100644 --- a/modules/reporting/lib/widget/filters/multi_choice.rb +++ b/modules/reporting/lib/widget/filters/multi_choice.rb @@ -29,19 +29,19 @@ class Widget::Filters::MultiChoice < Widget::Filters::Base def render filterName = filter_class.underscore_name - result = content_tag :div, id: "#{filterName}_arg_1", class: 'advanced-filters--filter-value' do + result = content_tag :div, id: "#{filterName}_arg_1", class: "advanced-filters--filter-value" do choices = filter_class.available_values.each_with_index.map do |(label, value), i| opts = { - type: 'radio', + type: "radio", name: "values[#{filterName}][]", id: "#{filterName}_radio_option_#{i}", value: } - opts[:checked] = 'checked' if filter.values == [value].flatten + opts[:checked] = "checked" if filter.values == [value].flatten radio_button = tag :input, opts content_tag :label, radio_button + translate(label), for: "#{filterName}_radio_option_#{i}", - 'data-filter-name': filter_class.underscore_name, + "data-filter-name": filter_class.underscore_name, class: "#{filterName}_radio_option filter_radio_option" end content_tag :div, choices.join.html_safe, diff --git a/modules/reporting/lib/widget/filters/multi_values.rb b/modules/reporting/lib/widget/filters/multi_values.rb index 1d11f22d658e..e4364e4016a5 100644 --- a/modules/reporting/lib/widget/filters/multi_values.rb +++ b/modules/reporting/lib/widget/filters/multi_values.rb @@ -28,37 +28,37 @@ class Widget::Filters::MultiValues < Widget::Filters::Base def render - write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: 'advanced-filters--filter-value') do - select_options = { 'data-remote-url': url_for(action: 'available_values'), - 'data-initially-selected': JSON::dump(Array(filter.values).flatten), - style: 'vertical-align: top;', # FIXME: Do CSS + write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: "advanced-filters--filter-value") do + select_options = { "data-remote-url": url_for(action: "available_values"), + "data-initially-selected": JSON::dump(Array(filter.values).flatten), + style: "vertical-align: top;", # FIXME: Do CSS name: "values[#{filter_class.underscore_name}][]", - 'data-loading': @options[:lazy] ? 'ajax' : '', + "data-loading": @options[:lazy] ? "ajax" : "", id: "#{filter_class.underscore_name}_arg_1_val", - class: 'form--select filter-value', - 'data-filter-name': filter_class.underscore_name } - box_content = ''.html_safe + class: "form--select filter-value", + "data-filter-name": filter_class.underscore_name } + box_content = "".html_safe label = label_tag "#{filter_class.underscore_name}_arg_1_val", - h(filter_class.label) + ' ' + I18n.t(:label_filter_value), - class: 'hidden-for-sighted' + h(filter_class.label) + " " + I18n.t(:label_filter_value), + class: "hidden-for-sighted" box = content_tag :select, select_options, id: "#{filter_class.underscore_name}_select_1" do render_widget Widget::Filters::Option, filter, to: box_content unless @options[:lazy] end plus = content_tag :a, - href: '#', - class: 'form-label filter_multi-select -transparent', - 'data-filter-name': filter_class.underscore_name, + href: "#", + class: "form-label filter_multi-select -transparent", + "data-filter-name": filter_class.underscore_name, title: I18n.t(:description_multi_select) do content_tag :span, - '', - class: 'icon-context icon-button icon-add icon4', + "", + class: "icon-context icon-button icon-add icon4", title: I18n.t(:label_enable_multi_select) do - content_tag :span, I18n.t(:label_enable_multi_select), class: 'hidden-for-sighted' + content_tag :span, I18n.t(:label_enable_multi_select), class: "hidden-for-sighted" end end - content_tag(:span, class: 'inline-label') do + content_tag(:span, class: "inline-label") do label + box + plus end end) diff --git a/modules/reporting/lib/widget/filters/project.rb b/modules/reporting/lib/widget/filters/project.rb index 496e2e84cbda..6b827053a49d 100644 --- a/modules/reporting/lib/widget/filters/project.rb +++ b/modules/reporting/lib/widget/filters/project.rb @@ -30,22 +30,22 @@ class Widget::Filters::Project < Widget::Filters::Base include AngularHelper def render - write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: 'advanced-filters--filter-value') do + write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: "advanced-filters--filter-value") do label = html_label selected_values = map_filter_values - box = angular_component_tag 'opce-project-autocompleter', + box = angular_component_tag "opce-project-autocompleter", inputs: { filters: [], - InputName: "values[#{filter_class.underscore_name}][]", + InputName: "values[#{filter_class.underscore_name}]", multiple: true, model: selected_values.filter { |item| !item.nil? } }, id: "#{filter_class.underscore_name}_select_1", - class: 'filter-value' + class: "filter-value" - content_tag(:span, class: 'inline-label') do + content_tag(:span, class: "inline-label") do label + box end end) @@ -56,13 +56,13 @@ def render def html_label label_tag "#{filter_class.underscore_name}_arg_1_val", "#{h(filter_class.label)} #{I18n.t(:label_filter_value)}", - class: 'hidden-for-sighted' + class: "hidden-for-sighted" end def map_filter_values # In case the filter values are all written in a single string (e.g. ["12, 33"]) if filter.values.length === 1 && filter.values[0].instance_of?(String) - filter.values = filter.values[0].split(',') + filter.values = filter.values[0].split(",") end filter.values.each.map do |id| diff --git a/spec/factories/custom_field_factory.rb b/spec/factories/custom_field_factory.rb index 5fbb5761f924..1c4616222b0e 100644 --- a/spec/factories/custom_field_factory.rb +++ b/spec/factories/custom_field_factory.rb @@ -33,24 +33,24 @@ # when using traits. They are not meant to be set externally. _format_name do [ - multi_value ? 'multi-' : nil, + multi_value ? "multi-" : nil, field_format ].compact.join end _type_name { instance.class.name.underscore.humanize(capitalize: false) } end sequence(:name) do |n, _e| - [_format_name, _type_name, n.to_s].join(' ').capitalize + [_format_name, _type_name, n.to_s].join(" ").capitalize end - regexp { '' } + regexp { "" } is_required { false } min_length { false } - default_value { '' } + default_value { "" } max_length { false } editable { true } - possible_values { '' } + possible_values { "" } visible { true } - field_format { 'bool' } + field_format { "bool" } after(:create) do # As the request store keeps track of the created custom fields @@ -58,29 +58,29 @@ end trait :boolean do - _format_name { 'boolean' } - field_format { 'bool' } + _format_name { "boolean" } + field_format { "bool" } end trait :string do - field_format { 'string' } + field_format { "string" } end trait :text do - field_format { 'text' } + field_format { "text" } end trait :integer do - _format_name { 'integer' } - field_format { 'int' } + _format_name { "integer" } + field_format { "int" } end trait :float do - field_format { 'float' } + field_format { "float" } end trait :date do - field_format { 'date' } + field_format { "date" } end trait :list do @@ -88,9 +88,9 @@ default_option { nil } default_options { nil } end - field_format { 'list' } + field_format { "list" } multi_value { false } - possible_values { ['A', 'B', 'C', 'D', 'E', 'F', 'G'] } + possible_values { ["A", "B", "C", "D", "E", "F", "G"] } # update custom options default value from the default_option transient # field for non-multiselect field @@ -140,24 +140,24 @@ end trait :version do - field_format { 'version' } + field_format { "version" } end trait :multi_version do - field_format { 'version' } + field_format { "version" } multi_value { true } end trait :user do - field_format { 'user' } + field_format { "user" } end trait :multi_user do - field_format { 'user' } + field_format { "user" } multi_value { true } end - factory :project_custom_field, class: 'ProjectCustomField' do + factory :project_custom_field, class: "ProjectCustomField" do project_custom_field_section transient do @@ -170,7 +170,9 @@ next if projects.blank? projects.each do |project| - create(:project_custom_field_project_mapping, project:, project_custom_field: custom_field) + unless project.project_custom_fields.include?(custom_field) + create(:project_custom_field_project_mapping, project:, project_custom_field: custom_field) + end end end @@ -185,12 +187,12 @@ factory :user_project_custom_field, traits: [:user] end - factory :user_custom_field, class: 'UserCustomField' + factory :user_custom_field, class: "UserCustomField" - factory :group_custom_field, class: 'GroupCustomField' + factory :group_custom_field, class: "GroupCustomField" - factory :wp_custom_field, class: 'WorkPackageCustomField' do - _type_name { 'WP custom field' } + factory :wp_custom_field, class: "WorkPackageCustomField" do + _type_name { "WP custom field" } is_filter { true } transient do @@ -214,16 +216,16 @@ factory :user_wp_custom_field, traits: [:user] end - factory :issue_custom_field, class: 'WorkPackageCustomField' do - _type_name { 'issue custom field' } + factory :issue_custom_field, class: "WorkPackageCustomField" do + _type_name { "issue custom field" } end - factory :time_entry_custom_field, class: 'TimeEntryCustomField' do - field_format { 'text' } + factory :time_entry_custom_field, class: "TimeEntryCustomField" do + field_format { "text" } end - factory :version_custom_field, class: 'VersionCustomField' do - field_format { 'text' } + factory :version_custom_field, class: "VersionCustomField" do + field_format { "text" } end end end diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index 202b6d8b6c82..a30791f83dec 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -26,9 +26,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -RSpec.describe 'Projects', 'editing settings', :js, :with_cuprite do +RSpec.describe "Projects", "editing settings", :js, :with_cuprite do let(:name_field) { FormFields::InputFormField.new :name } let(:parent_field) { FormFields::SelectFormField.new :parent } let(:permissions) { %i(edit_project) } @@ -38,74 +38,74 @@ end shared_let(:project) do - create(:project, name: 'Foo project', identifier: 'foo-project') + create(:project, name: "Foo project", identifier: "foo-project") end - it 'hides the field whose functionality is presented otherwise' do + it "hides the field whose functionality is presented otherwise" do visit project_settings_general_path(project.id) - expect(page).to have_no_text :all, 'Active' - expect(page).to have_no_text :all, 'Identifier' + expect(page).to have_no_text :all, "Active" + expect(page).to have_no_text :all, "Identifier" end - describe 'identifier edit' do - it 'updates the project identifier' do + describe "identifier edit" do + it "updates the project identifier" do visit projects_path click_on project.name - click_on 'Project settings' - click_on 'Change identifier' + click_on "Project settings" + click_on "Change identifier" expect(page).to have_content "Change the project's identifier".upcase - expect(page).to have_current_path '/projects/foo-project/identifier' + expect(page).to have_current_path "/projects/foo-project/identifier" - fill_in 'project[identifier]', with: 'foo-bar' - click_on 'Update' + fill_in "project[identifier]", with: "foo-bar" + click_on "Update" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." expect(page) .to have_current_path %r{/projects/foo-bar/settings/general} - expect(Project.first.identifier).to eq 'foo-bar' + expect(Project.first.identifier).to eq "foo-bar" end - it 'displays error messages on invalid input' do + it "displays error messages on invalid input" do visit project_identifier_path(project) - fill_in 'project[identifier]', with: 'FOOO' - click_on 'Update' + fill_in "project[identifier]", with: "FOOO" + click_on "Update" - expect(page).to have_content 'Identifier is invalid.' - expect(page).to have_current_path '/projects/foo-project/identifier' + expect(page).to have_content "Identifier is invalid." + expect(page).to have_current_path "/projects/foo-project/identifier" end end - context 'with optional and required custom fields' do + context "with optional and required custom fields" do let!(:optional_custom_field) do - create(:project_custom_field, name: 'Optional Foo', + create(:project_custom_field, name: "Optional Foo", is_for_all: true, projects: [project]) end let!(:required_custom_field) do - create(:project_custom_field, name: 'Required Foo', + create(:project_custom_field, name: "Required Foo", is_for_all: true, is_required: true, projects: [project]) end - it 'shows optional and required custom fields for edit without a separation' do - project.custom_field_values.last.value = 'FOO' + it "shows optional and required custom fields for edit without a separation" do + project.custom_field_values.last.value = "FOO" project.save! visit project_settings_general_path(project.id) - expect(page).to have_text 'Optional Foo' - expect(page).to have_text 'Required Foo' + expect(page).to have_text "Optional Foo" + expect(page).to have_text "Required Foo" end end - context 'with a length restricted custom field' do + context "with a length restricted custom field" do let!(:required_custom_field) do create(:string_project_custom_field, - name: 'Foo', + name: "Foo", min_length: 1, max_length: 2, is_for_all: true, @@ -113,114 +113,114 @@ end let(:foo_field) { FormFields::InputFormField.new required_custom_field } - it 'shows the errors of that field when saving (Regression #33766)' do + it "shows the errors of that field when saving (Regression #33766)" do visit project_settings_general_path(project.id) - expect(page).to have_content 'Foo' + expect(page).to have_content "Foo" # Enter something too long - foo_field.set_value '1234' + foo_field.set_value "1234" # It should cut of that remaining value - foo_field.expect_value '12' + foo_field.expect_value "12" - click_button 'Save' + click_button "Save" - expect(page).to have_text 'Successful update.' + expect(page).to have_text "Successful update." end end - context 'with a multi-select custom field' do - include_context 'ng-select-autocomplete helpers' + context "with a multi-select custom field" do + include_context "ng-select-autocomplete helpers" - let!(:list_custom_field) { create(:list_project_custom_field, name: 'List CF', multi_value: true, projects: [project]) } + let!(:list_custom_field) { create(:list_project_custom_field, name: "List CF", multi_value: true, projects: [project]) } let(:form_field) { FormFields::SelectFormField.new list_custom_field } - it 'can select multiple values' do + it "can select multiple values" do visit project_settings_general_path(project.id) - form_field.select_option 'A', 'B' + form_field.select_option "A", "B" - click_on 'Save' + click_on "Save" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." - form_field.expect_selected 'A', 'B' + form_field.expect_selected "A", "B" cvs = project.reload.custom_value_for(list_custom_field) expect(cvs.count).to eq 2 - expect(cvs.map(&:typed_value)).to contain_exactly 'A', 'B' + expect(cvs.map(&:typed_value)).to contain_exactly "A", "B" end end - context 'with a date custom field' do - let!(:date_custom_field) { create(:date_project_custom_field, name: 'Date', projects: [project]) } + context "with a date custom field" do + let!(:date_custom_field) { create(:date_project_custom_field, name: "Date", projects: [project]) } let(:form_field) { FormFields::InputFormField.new date_custom_field } - it 'can save and remove the date (Regression #37459)' do + it "can save and remove the date (Regression #37459)" do visit project_settings_general_path(project.id) - form_field.set_value '2021-05-26' + form_field.set_value "2021-05-26" form_field.send_keys :enter - click_on 'Save' + click_on "Save" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." - form_field.expect_value '2021-05-26' + form_field.expect_value "2021-05-26" cv = project.reload.custom_value_for(date_custom_field) - expect(cv.typed_value).to eq '2021-05-26'.to_date + expect(cv.typed_value).to eq "2021-05-26".to_date end end - context 'with a user not allowed to see the parent project' do - include_context 'ng-select-autocomplete helpers' + context "with a user not allowed to see the parent project" do + include_context "ng-select-autocomplete helpers" let(:parent_project) { create(:project) } - let(:parent_field) { FormFields::SelectFormField.new 'parent' } + let(:parent_field) { FormFields::SelectFormField.new "parent" } before do project.update_attribute(:parent, parent_project) end - it 'can update the project without destroying the relation to the parent' do + it "can update the project without destroying the relation to the parent" do visit project_settings_general_path(project.id) - fill_in 'Name', with: 'New project name' + fill_in "Name", with: "New project name" - parent_field.expect_selected I18n.t(:'api_v3.undisclosed.parent') + parent_field.expect_selected I18n.t(:"api_v3.undisclosed.parent") - click_on 'Save' + click_on "Save" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." project.reload expect(project.name) - .to eql 'New project name' + .to eql "New project name" expect(project.parent) .to eql parent_project end end - context 'with correct scoping of project custom fields' do + context "with correct scoping of project custom fields" do let!(:optional_custom_field_activated_in_project) do - create(:project_custom_field, name: 'Optional Foo', + create(:project_custom_field, name: "Optional Foo", is_for_all: true, projects: [project]) end let!(:optional_custom_field_not_activated_in_project) do - create(:project_custom_field, name: 'Optional Bar', + create(:project_custom_field, name: "Optional Bar", is_for_all: true) end - it 'shows only the custom fields that are activated in the project' do + it "shows only the custom fields that are activated in the project" do visit project_settings_general_path(project.id) - expect(page).to have_text 'Optional Foo' - expect(page).to have_no_text 'Optional Bar' + expect(page).to have_text "Optional Foo" + expect(page).to have_no_text "Optional Bar" end end end From 89f2d091b305249b7aa6b90e5dfc5199327b1ca9 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 19 Mar 2024 06:55:58 +0100 Subject: [PATCH 154/218] Update modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- .../models/xls_export/project/exporter/xls_integration_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb index 094bb9c2171a..094d91f51fd4 100644 --- a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb +++ b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb @@ -33,8 +33,7 @@ context 'with project description containing html' do before do - project.description = "This is an

html

description." - project.save!(validate: false) + project.update_column(:description, "This is an

html

description.") end it 'performs a successful export' do From 006ab51bc128942330839654dcd364a0c2c8c6f1 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 19 Mar 2024 06:56:26 +0100 Subject: [PATCH 155/218] Update spec/features/admin/project_custom_fields/create_spec.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- spec/features/admin/project_custom_fields/create_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb index c5c03bc3ee94..187f4e170f70 100644 --- a/spec/features/admin/project_custom_fields/create_spec.rb +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'Create project custom fields', :js do include_context 'with seeded project custom fields' - context 'with unsufficient permissions' do + context 'with insufficient permissions' do it 'is not accessible' do login_as(non_admin) visit new_admin_settings_project_custom_field_path From 613c7c931560be66694b299f0c87a320c856345e Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 19 Mar 2024 06:56:59 +0100 Subject: [PATCH 156/218] Update spec/features/admin/project_custom_fields/create_spec.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- spec/features/admin/project_custom_fields/create_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb index 187f4e170f70..d5ec1245ee12 100644 --- a/spec/features/admin/project_custom_fields/create_spec.rb +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -96,7 +96,7 @@ it 'prevents creating a new project custom field with an empty name' do click_on('Save') - # no server side validation shown, html5 validation is used + expect(page).to have_field 'custom_field_name', validation_message: 'Please fill in this field.' # expect no redirect expect(page).to have_no_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) From 944a3fe811a774e5a756aaacbe8f2adc87ca0f83 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 19 Mar 2024 06:57:05 +0100 Subject: [PATCH 157/218] Update spec/features/admin/project_custom_fields/edit_spec.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- spec/features/admin/project_custom_fields/edit_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/project_custom_fields/edit_spec.rb b/spec/features/admin/project_custom_fields/edit_spec.rb index b197fbdcef44..397797d58a15 100644 --- a/spec/features/admin/project_custom_fields/edit_spec.rb +++ b/spec/features/admin/project_custom_fields/edit_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'Edit project custom fields', :js do include_context 'with seeded project custom fields' - context 'with unsufficient permissions' do + context 'with insufficient permissions' do it 'is not accessible' do login_as(non_admin) visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) From 9cf5af6fc9593607b57f62c4328c7ebaa5de0ef3 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 19 Mar 2024 06:57:24 +0100 Subject: [PATCH 158/218] Update spec/features/admin/project_custom_fields/edit_spec.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- spec/features/admin/project_custom_fields/edit_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/project_custom_fields/edit_spec.rb b/spec/features/admin/project_custom_fields/edit_spec.rb index 397797d58a15..3d6349874bb8 100644 --- a/spec/features/admin/project_custom_fields/edit_spec.rb +++ b/spec/features/admin/project_custom_fields/edit_spec.rb @@ -84,7 +84,7 @@ fill_in('custom_field_name', with: '') click_on('Save') - # no server side validation shown, html5 validation is used + expect(page).to have_field 'custom_field_name', validation_message: 'Please fill in this field.' expect(page).to have_no_text('Successful update') From 772f0e7668bdfd677e5159d8a6e5636a7a04e205 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:39:38 +0100 Subject: [PATCH 159/218] Update spec/services/project_custom_field_project_mappings/toggle_service_spec.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- .../toggle_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb index 6b175eb9abb3..929514c70038 100644 --- a/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb +++ b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb @@ -178,7 +178,7 @@ }) end - it 'toggles visible, non-required fields' do + it 'does not toggle visible, non-required fields' do expect(project.project_custom_fields).to contain_exactly( visible_required_project_custom_field ) From 71cd1d7ae29c5dc0827f9339f40efe366100b25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 19 Mar 2024 13:11:41 +0100 Subject: [PATCH 160/218] Fix invitation spec invited users don't have an href so we cant track them on it --- .../user-autocompleter.component.ts | 6 +- .../helpers/angular/tracking-functions.ts | 6 +- spec/features/members/invitation_spec.rb | 80 +++++++++---------- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index 8cb2923d9c1a..c8a3e3a612d0 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -136,11 +136,11 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent unknown|null { - return (item) => item.href; + protected defaultTrackByFunction():(item:{ href:unknown, name:unknown }) => unknown|null { + return (item) => item.href || item.name; } protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { - return compareByAttribute('href'); + return compareByAttribute('href', 'name'); } } diff --git a/frontend/src/app/shared/helpers/angular/tracking-functions.ts b/frontend/src/app/shared/helpers/angular/tracking-functions.ts index 050740708f72..d21042019458 100644 --- a/frontend/src/app/shared/helpers/angular/tracking-functions.ts +++ b/frontend/src/app/shared/helpers/angular/tracking-functions.ts @@ -4,10 +4,12 @@ export function halHref(_index:number, item:T):string|nul return item.href; } -export function compareByAttribute(attribute:string) { +export function compareByAttribute(...attributes:string[]) { return (a:any, b:any) => { const bothNil = !a && !b; - return bothNil || (!!a && !!b && a[attribute] === b[attribute]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const same = !!a && !!b && attributes.every((attribute) => a[attribute] === b[attribute]); + return bothNil || (!!a && !!b && same); }; } diff --git a/spec/features/members/invitation_spec.rb b/spec/features/members/invitation_spec.rb index 8a6ac7c17b24..09e8017613f8 100644 --- a/spec/features/members/invitation_spec.rb +++ b/spec/features/members/invitation_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -RSpec.describe 'invite user via email', :js do - let!(:project) { create(:project, name: 'Project 1', identifier: 'project1', members: project_members) } - let!(:developer) { create(:project_role, name: 'Developer') } +RSpec.describe "invite user via email", :js, :with_cuprite do + let!(:project) { create(:project, name: "Project 1", identifier: "project1", members: project_members) } + let!(:developer) { create(:project_role, name: "Developer") } let(:project_members) { {} } let(:members_page) { Pages::Members.new project.identifier } @@ -41,7 +41,7 @@ member_with_permissions: { project => %i[view_members manage_members] }) end - context 'with a new user' do + context "with a new user" do before do @old_value = Capybara.raise_server_errors Capybara.raise_server_errors = false @@ -51,27 +51,27 @@ Capybara.raise_server_errors = @old_value end - it 'adds the invited user to the project' do + it "adds the invited user to the project" do members_page.visit! members_page.open_new_member! - members_page.search_and_select_principal! 'finkelstein@openproject.com', - 'Send invite to finkelstein@openproject.com' - members_page.select_role! 'Developer' - expect(members_page).to have_selected_new_principal('finkelstein@openproject.com') + members_page.search_and_select_principal! "finkelstein@openproject.com", + "Send invite to finkelstein@openproject.com" + members_page.select_role! "Developer" + expect(members_page).to have_selected_new_principal("finkelstein@openproject.com") - click_on 'Add' + click_on "Add" - expect(members_page).to have_added_user('finkelstein @openproject.com') + expect(members_page).to have_added_user("finkelstein @openproject.com") - expect(members_page).to have_user 'finkelstein @openproject.com' + expect(members_page).to have_user "finkelstein @openproject.com" # Should show the invited user on the default filter as well members_page.visit! - expect(members_page).to have_user 'finkelstein @openproject.com' + expect(members_page).to have_user "finkelstein @openproject.com" end - context 'with an instance with a user limit (regression)' do + context "with an instance with a user limit (regression)" do before do allow(OpenProject::Enterprise).to receive_messages( user_limit: 10, @@ -79,18 +79,18 @@ ) end - it 'shows a warning when the limit is reached' do + it "shows a warning when the limit is reached" do members_page.visit! members_page.open_new_member! - members_page.search_and_select_principal! 'finkelstein@openproject.com', - 'Send invite to finkelstein@openproject.com' + members_page.search_and_select_principal! "finkelstein@openproject.com", + "Send invite to finkelstein@openproject.com" expect(members_page).to have_no_text sanitize_string(I18n.t(:warning_user_limit_reached)), normalize_ws: true - members_page.search_and_select_principal! 'frankenstein@openproject.com', - 'Send invite to frankenstein@openproject.com' + members_page.search_and_select_principal! "frankenstein@openproject.com", + "Send invite to frankenstein@openproject.com" expect(members_page).to have_text sanitize_string(I18n.t(:warning_user_limit_reached)), normalize_ws: true @@ -98,44 +98,44 @@ end end - context 'with a registered user' do + context "with a registered user" do let!(:user) do - create(:user, mail: 'hugo@openproject.com', - login: 'hugo@openproject.com', - firstname: 'Hugo', - lastname: 'Hurried') + create(:user, mail: "hugo@openproject.com", + login: "hugo@openproject.com", + firstname: "Hugo", + lastname: "Hurried") end - it 'user lookup by email' do + it "user lookup by email" do members_page.visit! retry_block do members_page.open_new_member! - find_by_id('members_add_form') + find_by_id("members_add_form") end - members_page.search_and_select_principal! 'hugo@openproject.com', - 'Hugo Hurried' - members_page.select_role! 'Developer' + members_page.search_and_select_principal! "hugo@openproject.com", + "Hugo Hurried" + members_page.select_role! "Developer" - click_on 'Add' - expect(members_page).to have_added_user 'Hugo Hurried' + click_on "Add" + expect(members_page).to have_added_user "Hugo Hurried" end - context 'who is already a member' do + context "who is already a member" do let(:project_members) { { user => developer } } - shared_examples 'no user to invite is found' do - it 'no matches found' do + shared_examples "no user to invite is found" do + it "no matches found" do members_page.visit! members_page.open_new_member! - members_page.search_principal! 'hugo@openproject.com' + members_page.search_principal! "hugo@openproject.com" expect(members_page).to have_no_search_results end end - it_behaves_like 'no user to invite is found' + it_behaves_like "no user to invite is found" ## # This is a edge case where the email address to be invited is free in principle @@ -143,13 +143,13 @@ # cannot be used after all as the login is the same as the email address for new users # which means the login for this invited user will already by taken. # Accordingly it should not be offered to invite a user with that email address. - context 'with different email but email as login' do + context "with different email but email as login" do before do - user.mail = 'foo@bar.de' + user.mail = "foo@bar.de" user.save! end - it_behaves_like 'no user to invite is found' + it_behaves_like "no user to invite is found" end end end From 791155312bd1c13b20ee065f4464d9211cf54bb5 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 19 Mar 2024 12:07:36 +0200 Subject: [PATCH 161/218] Rename project custom field project mappings contract validation methods. --- .../base_contract.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/contracts/project_custom_field_project_mappings/base_contract.rb b/app/contracts/project_custom_field_project_mappings/base_contract.rb index 2e18e2beb564..106abf73b07f 100644 --- a/app/contracts/project_custom_field_project_mappings/base_contract.rb +++ b/app/contracts/project_custom_field_project_mappings/base_contract.rb @@ -31,17 +31,17 @@ class BaseContract < ::ModelContract attribute :project_id attribute :custom_field_id - validate :validate_has_select_project_custom_fields_permission - validate :validate_is_not_required - validate :validate_is_visbile_to_user + validate :select_project_custom_fields_permission + validate :not_required + validate :visbile_to_user - def validate_has_select_project_custom_fields_permission + def select_project_custom_fields_permission return if user.allowed_in_project?(:select_project_custom_fields, model.project) errors.add :base, :error_unauthorized end - def validate_is_not_required + def not_required # only mappings of custom fields which are not required can be manipulated by the user # enabling a custom field which is required happens in an after_save hook within the custom field model itself return if model.project_custom_field.nil? || !model.project_custom_field.required? @@ -49,7 +49,7 @@ def validate_is_not_required errors.add :custom_field_id, :invalid end - def validate_is_visbile_to_user + def visbile_to_user # "invisible" custom fields can only be seen and edited by admins # using visible scope to check if the custom field is actually visible to the user return if model.project_custom_field.nil? || ProjectCustomField.visible.pluck(:id).include?(model.project_custom_field.id) From b1444429be89a103116160756a48dad29641e789 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 19 Mar 2024 21:58:48 +0200 Subject: [PATCH 162/218] Avoid N+1 queries on the ProjectCustomFieldSections Additionally moving the custom_fields relation from the CustomFieldSection to the ProjectCustomFieldSection. This is required, because we have to specify the custom_fields relation to include ProjectCustomField objects only. Otherwise the nested include query in the ProjectCustomFieldsController does not work. --- .../settings/project_custom_field_sections/show_component.rb | 2 +- .../admin/settings/project_custom_fields_controller.rb | 1 + app/models/custom_field_section.rb | 2 -- app/models/project_custom_field_section.rb | 1 + 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index e9fc4eab56b9..1f3ed82c3925 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -37,7 +37,7 @@ def initialize(project_custom_field_section:) super @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields.reorder(position_in_custom_field_section: :asc) + @project_custom_fields = project_custom_field_section.custom_fields end private diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 9dcc5169df0d..f25cca6013da 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -111,6 +111,7 @@ def destroy def set_sections @project_custom_field_sections = ProjectCustomFieldSection .includes(custom_fields: :project_custom_field_project_mappings) + .order("custom_fields.position_in_custom_field_section ASC") .all end diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb index 7d1cd811a4ad..8ca5ae147a02 100644 --- a/app/models/custom_field_section.rb +++ b/app/models/custom_field_section.rb @@ -27,8 +27,6 @@ #++ class CustomFieldSection < ApplicationRecord - has_many :custom_fields, dependent: :destroy - acts_as_list scope: [:type] validates :name, presence: true diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index fcfe4b98ae50..22a9ef3114e0 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -27,4 +27,5 @@ #++ class ProjectCustomFieldSection < CustomFieldSection + has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy end From 5415efbd9e2d1565c99ae111f3907ac32924d3d4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 19 Mar 2024 16:04:54 +0700 Subject: [PATCH 163/218] checking for correct handling of invisible fields in export specs as suggested by @dombesz --- .../project/exporter/xls_integration_spec.rb | 125 ++++++++---- spec/models/projects/customizable_spec.rb | 193 +++++++++++------- .../projects/exporter/csv_integration_spec.rb | 118 +++++++---- .../exporter/exportable_project_context.rb | 30 +-- 4 files changed, 293 insertions(+), 173 deletions(-) diff --git a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb index 094d91f51fd4..21a2a620382d 100644 --- a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb +++ b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb @@ -1,10 +1,10 @@ -require 'spec_helper' -require 'spreadsheet' -require 'models/projects/exporter/exportable_project_context' +require "spec_helper" +require "spreadsheet" +require "models/projects/exporter/exportable_project_context" RSpec.describe XlsExport::Project::Exporter::XLS do - include_context 'with a project with an arrangement of custom fields' - include_context 'with an instance of the described exporter' + include_context "with a project with an arrangement of custom fields" + include_context "with an instance of the described exporter" let(:sheet) do io = StringIO.new output @@ -14,89 +14,126 @@ let(:header) { sheet.rows.first.compact } # raw values have trailing nil let(:rows) { sheet.rows.drop(1) } - describe 'empty result' do + describe "empty result" do before do allow(instance).to receive(:records).and_return([]) end - it 'returns an empty XLS' do + it "returns an empty XLS" do expect(sheet.rows.count).to eq 1 expect(rows).to be_empty end end - it 'performs a successful export' do + it "performs a successful export" do expect(rows.count).to eq(1) expect(sheet.row(1)).to eq [project.id.to_s, project.identifier, - project.name, project.description, 'Off track', 'false'] + project.name, project.description, "Off track", "false"] end - context 'with project description containing html' do + context "with project description containing html" do before do project.update_column(:description, "This is an

html

description.") end - it 'performs a successful export' do + it "performs a successful export" do expect(rows.count).to eq(1) expect(sheet.row(1)).to eq [project.id.to_s, project.identifier, project.name, - "This is an html description.", 'Off track', 'false'] + "This is an html description.", "Off track", "false"] end end - context 'with status_explanation enabled' do - let(:query_columns) { %w[name description project_status status_explanation public ] } + context "with status_explanation enabled" do + let(:query_columns) { %w[name description project_status status_explanation public] } - it 'performs a successful export' do + it "performs a successful export" do expect(rows.count).to eq(1) expect(sheet.row(1)).to eq [project.id.to_s, project.identifier, project.name, project.description, - 'Off track', project.status_explanation, 'false'] + "Off track", project.status_explanation, "false"] end end - describe 'custom field columns selected' do + describe "custom field columns selected" do let(:query_columns) { %w[name description project_status public] + global_project_custom_fields.map(&:column_name) } - let(:current_user) { build_stubbed(:admin) } - - context 'when ee enabled', with_ee: %i[custom_fields_in_projects_list] do - it 'renders all those columns' do - cf_names = global_project_custom_fields.map(&:name) - expect(header).to eq ['ID', 'Identifier', 'Name', 'Description', 'Status', 'Public', *cf_names] - - custom_values = global_project_custom_fields.map do |cf| - case cf - when bool_cf - 'true' - when text_cf - project.typed_custom_value_for(cf) - when not_used_string_cf - nil - else - project.formatted_custom_value_for(cf) + + context "when ee enabled", with_ee: %i[custom_fields_in_projects_list] do + before do + project # re-evaluate project to ensure it is created within the desired user context + end + + context "with admin permission" do + let(:current_user) { build_stubbed(:admin) } + + it "renders all those columns" do + cf_names = global_project_custom_fields.map(&:name) + expect(header).to eq ["ID", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + + expect(header).to include not_used_string_cf.name + expect(header).to include hidden_cf.name + + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when text_cf + project.typed_custom_value_for(cf) + when not_used_string_cf + nil + else + project.formatted_custom_value_for(cf) + end end + + expect(sheet.row(1)) + .to eq [project.id.to_s, project.identifier, project.name, project.description, "Off track", "false", + *custom_values] + + # The column for the project-level-disabled custom field is blank + expect(sheet.row(1)[header.index(not_used_string_cf.name)]).to be_nil end + end - expect(sheet.row(1)) - .to eq [project.id.to_s, project.identifier, project.name, project.description, 'Off track', 'false', - *custom_values] + context "without admin permission" do + it "renders all visible globally available project custom fields in the header" do + cf_names = global_project_custom_fields.map(&:name) + + expect(header).to eq ["ID", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + + expect(header).to include not_used_string_cf.name + expect(header).not_to include hidden_cf.name + + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when text_cf + project.typed_custom_value_for(cf) + when not_used_string_cf + nil + else + project.formatted_custom_value_for(cf) + end + end - # The column for the project-level-disabled custom field is blank - expect(sheet.row(1)[header.index(not_used_string_cf.name)]).to be_nil - # TODO: CSV export renders "" instead of nil, why does XLS export render nil? + expect(sheet.row(1)) + .to eq [project.id.to_s, project.identifier, project.name, project.description, "Off track", "false", + *custom_values] + end end end - context 'when ee not enabled' do - it 'renders only the default columns' do + context "when ee not enabled" do + it "renders only the default columns" do expect(header).to eq %w[ID Identifier Name Description Status Public] end end end - context 'with no project visible' do + context "with no project visible" do let(:current_user) { User.anonymous } - it 'does not include the project' do + it "does not include the project" do expect(output).not_to include project.identifier expect(rows).to be_empty end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index a215180f7191..65e092bf6706 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -26,8 +26,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -RSpec.describe Project, 'customizable' do +require "spec_helper" +RSpec.describe Project, "customizable" do let!(:section) { create(:project_custom_field_section) } let!(:bool_custom_field) do @@ -40,11 +40,11 @@ create(:list_project_custom_field, project_custom_field_section: section) end - context 'when not persisted' do + context "when not persisted" do let(:project) { build(:project) } - describe '#available_custom_fields' do - it 'returns all existing project custom fields as available custom fields' do + describe "#available_custom_fields" do + it "returns all existing project custom fields as available custom fields" do expect(project.project_custom_field_project_mappings) .to be_empty expect(project.project_custom_fields) @@ -56,11 +56,11 @@ end end - context 'when persisted' do + context "when persisted" do let(:project) { create(:project) } - describe '#available_custom_fields' do - it 'returns only mapped project custom fields as available custom fields' do + describe "#available_custom_fields" do + it "returns only mapped project custom fields as available custom fields" do expect(project.project_custom_field_project_mappings) .to be_empty expect(project.project_custom_fields) @@ -76,9 +76,9 @@ end end - describe '#custom_field_values and #custom_value_for' do - context 'when no custom fields are mapped to this project' do - it '#custom_value_for returns nil' do + describe "#custom_field_values and #custom_value_for" do + context "when no custom fields are mapped to this project" do + it "#custom_value_for returns nil" do expect(project.custom_value_for(text_custom_field)) .to be_nil expect(project.custom_value_for(bool_custom_field)) @@ -87,19 +87,19 @@ .to be_nil end - it '#custom_field_values returns an empty hash' do + it "#custom_field_values returns an empty hash" do expect(project.custom_field_values) .to be_empty end end - context 'when custom fields are mapped to this project' do + context "when custom fields are mapped to this project" do before do project.project_custom_fields << [text_custom_field, bool_custom_field] project.reload # TODO: why is this necessary? end - it '#custom_field_values returns a hash of mapped custom fields with nil values' do + it "#custom_field_values returns a hash of mapped custom fields with nil values" do text_custom_field_custom_field_value = project.custom_field_values.find do |custom_value| custom_value.custom_field_id == text_custom_field.id end @@ -115,28 +115,28 @@ expect(bool_custom_field_custom_field_value.value).to be_nil end - context 'when values are set for mapped custom fields' do + context "when values are set for mapped custom fields" do before do project.custom_field_values = { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true } end - it '#custom_value_for returns the set custom values' do + it "#custom_value_for returns the set custom values" do expect(project.custom_value_for(text_custom_field).typed_value) - .to eq('foo') + .to eq("foo") expect(project.custom_value_for(bool_custom_field).typed_value) .to be_truthy expect(project.custom_value_for(list_custom_field).typed_value) .to be_nil end - it '#custom_field_values returns a hash of mapped custom fields with their set values' do + it "#custom_field_values returns a hash of mapped custom fields with their set values" do expect(project.custom_field_values.find do |custom_value| custom_value.custom_field_id == text_custom_field.id end.typed_value) - .to eq('foo') + .to eq("foo") expect(project.custom_field_values.find do |custom_value| custom_value.custom_field_id == bool_custom_field.id @@ -148,22 +148,22 @@ end end - context 'when creating with custom field values' do + context "when creating with custom field values" do let(:project) do create(:project, custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) end - it 'saves the custom field values properly' do + it "saves the custom field values properly" do expect(project.custom_value_for(text_custom_field).typed_value) - .to eq('foo') + .to eq("foo") expect(project.custom_value_for(bool_custom_field).typed_value) .to be_truthy end - it 'enables fields with provided values and disables fields with none' do + it "enables fields with provided values and disables fields with none" do # list_custom_field is not provided, thus it should not be enabled expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) .to contain_exactly(text_custom_field.id, bool_custom_field.id) @@ -171,7 +171,7 @@ .to contain_exactly(text_custom_field, bool_custom_field) end - context 'with correct validation' do + context "with correct validation" do let(:another_section) { create(:project_custom_field_section) } let!(:required_text_custom_field) do @@ -180,9 +180,9 @@ project_custom_field_section: another_section) end - it 'validates all custom values if not scoped to a section' do + it "validates all custom values if not scoped to a section" do project = build(:project, custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) @@ -191,9 +191,9 @@ expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) end - it 'rejects section validation scoping for project creation' do + it "rejects section validation scoping for project creation" do project = build(:project, custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }, _limit_custom_fields_validation_to_section_id: section.id) @@ -201,11 +201,11 @@ expect { project.save! }.to raise_error(ArgumentError) end - it 'temporarly validates only custom values of a section if section scope is provided while updating' do + it "temporarly validates only custom values of a section if section scope is provided while updating" do project = create(:project, custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true, - required_text_custom_field.id => 'bar' + required_text_custom_field.id => "bar" }) expect(project).to be_valid @@ -233,27 +233,27 @@ end end - context 'with correct handling of custom fields with default values' do + context "with correct handling of custom fields with default values" do let!(:text_custom_field_with_default) do create(:text_project_custom_field, - default_value: 'default', + default_value: "default", project_custom_field_section: section) end - it 'activates custom fields with default values if not explicitly set to blank' do + it "activates custom fields with default values if not explicitly set to blank" do project = create(:project, custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) .to contain_exactly(text_custom_field.id, bool_custom_field.id, text_custom_field_with_default.id) end - it 'does not activate custom fields with default values if explicitly set to blank' do + it "does not activate custom fields with default values if explicitly set to blank" do project = create(:project, custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true, - text_custom_field_with_default.id => '' + text_custom_field_with_default.id => "" }) expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) .to contain_exactly(text_custom_field.id, bool_custom_field.id) @@ -261,11 +261,11 @@ end end - context 'when updating with custom field values' do + context "when updating with custom field values" do let!(:project) { create(:project) } - shared_examples 'implicitly enabled and saved custom values' do - it 'enables fields with provided values' do + shared_examples "implicitly enabled and saved custom values" do + it "enables fields with provided values" do # list_custom_field is not provided, thus it should not be enabled expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) .to contain_exactly(text_custom_field.id, bool_custom_field.id) @@ -273,52 +273,52 @@ .to contain_exactly(text_custom_field, bool_custom_field) end - it 'saves the custom field values properly' do + it "saves the custom field values properly" do expect(project.custom_value_for(text_custom_field).typed_value) - .to eq('foo') + .to eq("foo") expect(project.custom_value_for(bool_custom_field).typed_value) .to be_truthy end end - context 'with #update method' do + context "with #update method" do before do project.update(custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) end - it_behaves_like 'implicitly enabled and saved custom values' + it_behaves_like "implicitly enabled and saved custom values" end - context 'with #update! method' do + context "with #update! method" do before do project.update!(custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) end - it_behaves_like 'implicitly enabled and saved custom values' + it_behaves_like "implicitly enabled and saved custom values" end - context 'with #custom_field_values= method' do + context "with #custom_field_values= method" do before do project.custom_field_values = { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true } project.save! end - it_behaves_like 'implicitly enabled and saved custom values' + it_behaves_like "implicitly enabled and saved custom values" end - it 'does not re-enable fields without new value which have been disabled in the past (regression)' do + it "does not re-enable fields without new value which have been disabled in the past (regression)" do project.update!(custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) @@ -338,16 +338,16 @@ .to contain_exactly(bool_custom_field) end - context 'with correct handling of custom fields with default values' do + context "with correct handling of custom fields with default values" do let!(:text_custom_field_with_default) do create(:text_project_custom_field, - default_value: 'default', + default_value: "default", project_custom_field_section: section) end - it 'does not activate custom fields with default values if not explicitly set to a value' do + it "does not activate custom fields with default values if not explicitly set to a value" do project.update!(custom_field_values: { - text_custom_field.id => 'bar', + text_custom_field.id => "bar", bool_custom_field.id => false }) @@ -356,11 +356,11 @@ .to contain_exactly(text_custom_field.id, bool_custom_field.id) end - it 'does activate custom fields with default values if explicitly set to a value' do + it "does activate custom fields with default values if explicitly set to a value" do project.update!(custom_field_values: { - text_custom_field.id => 'bar', + text_custom_field.id => "bar", bool_custom_field.id => false, - text_custom_field_with_default.id => 'overwritten default' + text_custom_field_with_default.id => "overwritten default" }) # text_custom_field_with_default is not provided, thus it should not be enabled (in contrast to creation) @@ -369,9 +369,9 @@ end end - it 'does re-enable fields with new value which have been disabled in the past' do + it "does re-enable fields with new value which have been disabled in the past" do project.update!(custom_field_values: { - text_custom_field.id => 'foo', + text_custom_field.id => "foo", bool_custom_field.id => true }) @@ -384,22 +384,22 @@ .to contain_exactly(bool_custom_field) project.update!(custom_field_values: { - text_custom_field.id => 'bar' + text_custom_field.id => "bar" }) expect(project.reload.project_custom_fields) .to contain_exactly(text_custom_field, bool_custom_field) expect(project.custom_value_for(text_custom_field).typed_value) - .to eq('bar') + .to eq("bar") end end - context 'when updating with custom field setter methods (API approach)' do + context "when updating with custom field setter methods (API approach)" do let(:project) { create(:project) } - shared_examples 'implicitly enabled and saved custom values' do - it 'enables fields with provided values' do + shared_examples "implicitly enabled and saved custom values" do + it "enables fields with provided values" do # list_custom_field is not provided, thus it should not be enabled expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) .to contain_exactly(text_custom_field.id, bool_custom_field.id) @@ -407,27 +407,68 @@ .to contain_exactly(text_custom_field, bool_custom_field) end - it 'saves the custom field values properly' do + it "saves the custom field values properly" do expect(project.custom_value_for(text_custom_field).typed_value) - .to eq('foo') + .to eq("foo") expect(project.custom_value_for(bool_custom_field).typed_value) .to be_truthy # or via getter methods: - expect(project.send(:"custom_field_#{text_custom_field.id}")).to eq('foo') + expect(project.send(:"custom_field_#{text_custom_field.id}")).to eq("foo") expect(project.send(:"custom_field_#{bool_custom_field.id}")).to be_truthy end end - context 'when setting a value for a disabled custom field' do + context "when setting a value for a disabled custom field" do before do - project.send(:"custom_field_#{text_custom_field.id}=", 'foo') + project.send(:"custom_field_#{text_custom_field.id}=", "foo") project.send(:"custom_field_#{bool_custom_field.id}=", true) project.save! end - it_behaves_like 'implicitly enabled and saved custom values' + it_behaves_like "implicitly enabled and saved custom values" + end + end + + context "with hidden custom fields" do + let!(:hidden_custom_field) do + create(:text_project_custom_field, project_custom_field_section: section, visible: false) + end + let(:project) do + create(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true, + hidden_custom_field.id => "hidden" + }) + end + + before do + User.current = user # needs to be executed before project creation! + end + + context "with admin permission" do + let(:user) { create(:admin) } + + it "does activate hidden custom fields" do + # project creation happens with an admin user as let(:project) called after setting the current user to an admin + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id, hidden_custom_field.id) + + expect(project.custom_value_for(hidden_custom_field)).to eq("hidden") + end + end + + context "without admin permission" do + let(:user) { create(:user) } + + it "does not activate hidden custom fields" do + # project creation happens with an non-admin user as let(:project) called after setting the current user to an non-admin + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + + expect(project.custom_value_for(hidden_custom_field)).to be_nil + end end end end diff --git a/spec/models/projects/exporter/csv_integration_spec.rb b/spec/models/projects/exporter/csv_integration_spec.rb index 3de1c4e0a001..464e77efa57d 100644 --- a/spec/models/projects/exporter/csv_integration_spec.rb +++ b/spec/models/projects/exporter/csv_integration_spec.rb @@ -26,12 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative 'exportable_project_context' +require "spec_helper" +require_relative "exportable_project_context" -RSpec.describe Projects::Exports::CSV, 'integration' do - include_context 'with a project with an arrangement of custom fields' - include_context 'with an instance of the described exporter' +RSpec.describe Projects::Exports::CSV, "integration" do + include_context "with a project with an arrangement of custom fields" + include_context "with an instance of the described exporter" let(:parsed) do CSV.parse(output) @@ -41,74 +41,112 @@ let(:rows) { parsed.drop(1) } - it 'performs a successful export' do + it "performs a successful export" do expect(parsed.size).to eq(2) expect(parsed.last).to eq [project.id.to_s, project.identifier, - project.name, project.description, 'Off track', 'false'] + project.name, project.description, "Off track", "false"] end - context 'with status_explanation enabled' do + context "with status_explanation enabled" do let(:query_columns) { %w[name description project_status status_explanation public] } - it 'performs a successful export' do + it "performs a successful export" do expect(parsed.size).to eq(2) expect(parsed.last).to eq [project.id.to_s, project.identifier, project.name, project.description, - 'Off track', 'some explanation', 'false'] + "Off track", "some explanation", "false"] end end - describe 'custom field columns selected' do + describe "custom field columns selected" do let(:query_columns) do %w[name description project_status public] + global_project_custom_fields.map(&:column_name) end - context 'when ee enabled', with_ee: %i[custom_fields_in_projects_list] do - it 'renders all globally available project custom fields in the header' do - expect(parsed.size).to eq 2 + context "when ee enabled", with_ee: %i[custom_fields_in_projects_list] do + before do + project # re-evaluate project to ensure it is created within the desired user context + parsed + end - cf_names = global_project_custom_fields.map(&:name) + context "without admin permission" do + it "renders all visible globally available project custom fields in the header" do + expect(parsed.size).to eq 2 - expect(cf_names).to include(not_used_string_cf.name) + cf_names = global_project_custom_fields.map(&:name) - expect(header).to eq ['id', 'Identifier', 'Name', 'Description', 'Status', 'Public', *cf_names] - end + expect(cf_names).to include(not_used_string_cf.name) + expect(cf_names).not_to include(hidden_cf.name) + + expect(header).to eq ["id", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + end - it 'renders the custom field values in the rows if enabled for a project' do - expect(parsed.size).to eq 2 - - custom_values = global_project_custom_fields.map do |cf| - case cf - when bool_cf - 'true' - when text_cf - project.typed_custom_value_for(cf) - when not_used_string_cf - '' - else - project.formatted_custom_value_for(cf) + it "renders the custom field values in the rows if enabled for a project" do + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when text_cf + project.typed_custom_value_for(cf) + when not_used_string_cf + "" + else + project.formatted_custom_value_for(cf) + end end + expect(rows.first) + .to eq [project.id.to_s, project.identifier, project.name, + project.description, "Off track", "false", *custom_values] end - expect(rows.first) - .to eq [project.id.to_s, project.identifier, project.name, - project.description, 'Off track', 'false', *custom_values] + end + + context "with admin permission" do + let(:current_user) { create(:admin) } + + it "renders all globally available project custom fields including hidden ones in the header" do + expect(parsed.size).to eq 3 - # The column for the project-level-disabled custom field is blank - expect(rows.first[header.index(not_used_string_cf.name)]).to eq '' + cf_names = global_project_custom_fields.map(&:name) + + expect(cf_names).to include(not_used_string_cf.name) + expect(cf_names).to include(hidden_cf.name) + + expect(header).to eq ["id", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + end + + it "renders the custom field values in the rows if enabled for a project" do + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when hidden_cf + "hidden" + when not_used_string_cf + "" + when text_cf + project.typed_custom_value_for(cf) + else + project.formatted_custom_value_for(cf) + end + end + expect(rows.first) + .to eq [project.id.to_s, project.identifier, project.name, + project.description, "Off track", "false", *custom_values] + end end end - context 'when ee not enabled' do - it 'renders only the default columns' do + context "when ee not enabled" do + it "renders only the default columns" do expect(header).to eq %w[id Identifier Name Description Status Public] end end end - context 'with no project visible' do + context "with no project visible" do let(:current_user) { User.anonymous } - it 'does not include the project' do + it "does not include the project" do expect(output).not_to include project.identifier expect(parsed.size).to eq(1) end diff --git a/spec/models/projects/exporter/exportable_project_context.rb b/spec/models/projects/exporter/exportable_project_context.rb index 512155930f9e..77960e542566 100644 --- a/spec/models/projects/exporter/exportable_project_context.rb +++ b/spec/models/projects/exporter/exportable_project_context.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -RSpec.shared_context 'with a project with an arrangement of custom fields' do +RSpec.shared_context "with a project with an arrangement of custom fields" do shared_let(:version_cf) { create(:version_project_custom_field, position: 1) } shared_let(:bool_cf) { create(:boolean_project_custom_field, position: 2) } shared_let(:user_cf) { create(:user_project_custom_field, position: 3) } @@ -35,10 +35,11 @@ shared_let(:text_cf) { create(:text_project_custom_field, position: 6) } shared_let(:string_cf) { create(:string_project_custom_field, position: 7) } shared_let(:date_cf) { create(:date_project_custom_field, position: 8) } + shared_let(:hidden_cf) { create(:string_project_custom_field, position: 9, visible: false) } - let!(:not_used_string_cf) { create(:string_project_custom_field, position: 9) } + let!(:not_used_string_cf) { create(:string_project_custom_field, position: 10) } - shared_let(:system_version) { create(:version, sharing: 'system') } + shared_let(:system_version) { create(:version, sharing: "system") } shared_let(:role) do create(:project_role) @@ -46,14 +47,16 @@ shared_let(:other_user) do create(:user, - firstname: 'Other', - lastname: 'User') + firstname: "Other", + lastname: "User") end - shared_let(:project) do + # project needs to be reevaluted before every example as the creation behaves differently from different user contexts + # shared_let cannot be used here as it would create the project only once + let(:project) do project = build(:project, - status_code: 'off_track', - status_explanation: 'some explanation', + status_code: "off_track", + status_explanation: "some explanation", members: { other_user => role }, description: "The description of the project", custom_field_values: { @@ -61,10 +64,11 @@ bool_cf.id => true, version_cf.id => system_version, float_cf.id => 4.5, - text_cf.id => 'Some **long** text', - string_cf.id => 'Some small text', + text_cf.id => "Some **long** text", + string_cf.id => "Some small text", date_cf.id => Time.zone.today, - user_cf.id => other_user.id + user_cf.id => other_user.id, + hidden_cf.id => "hidden" }) project.save!(validate: false) @@ -72,7 +76,7 @@ end end -RSpec.shared_context 'with an instance of the described exporter' do +RSpec.shared_context "with an instance of the described exporter" do before do login_as current_user end @@ -89,7 +93,7 @@ described_class.new(query) end - let(:global_project_custom_fields) { ProjectCustomField.all } + let(:global_project_custom_fields) { ProjectCustomField.visible } let(:custom_fields_of_project) { project.available_custom_fields } let(:output) do From 37f2b9c481b7e7f4fc0ce86a8c78f096728713fe Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Tue, 19 Mar 2024 16:05:31 +0700 Subject: [PATCH 164/218] cleanup resolved comment --- spec/features/projects/copy_spec.rb | 178 ++++++++++++++-------------- 1 file changed, 86 insertions(+), 92 deletions(-) diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 7ba1583efbfc..825d4f7f506a 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -26,10 +26,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -RSpec.describe 'Projects copy', :js, :with_cuprite do - describe 'with a full copy example' do +RSpec.describe "Projects copy", :js, :with_cuprite do + describe "with a full copy example" do let!(:project) do create(:project, parent: parent_project, @@ -37,15 +37,15 @@ members: { user => role }, # custom_fields which are not used below are not activated for this project custom_field_values: { - project_custom_field.id => 'some text cf', - optional_project_custom_field.id => 'some optional text cf', - optional_project_custom_field_with_default.id => 'foo' + project_custom_field.id => "some text cf", + optional_project_custom_field.id => "some optional text cf", + optional_project_custom_field_with_default.id => "foo" }).tap do |p| p.work_package_custom_fields << wp_custom_field p.types.first.custom_fields << wp_custom_field # Enable wiki - p.enabled_module_names += ['wiki'] + p.enabled_module_names += ["wiki"] end end @@ -58,7 +58,7 @@ roles: [role]) project end - let!(:project_custom_field_section) { create(:project_custom_field_section, name: 'Section A') } + let!(:project_custom_field_section) { create(:project_custom_field_section, name: "Section A") } let!(:project_custom_field) do create(:text_project_custom_field, is_required: true, project_custom_field_section:) end @@ -66,7 +66,7 @@ create(:text_project_custom_field, is_required: false, project_custom_field_section:) end let!(:optional_project_custom_field_with_default) do - create(:text_project_custom_field, is_required: false, default_value: 'foo', project_custom_field_section:) + create(:text_project_custom_field, is_required: false, default_value: "foo", project_custom_field_section:) end let!(:wp_custom_field) do create(:text_wp_custom_field) @@ -121,17 +121,17 @@ done_ratio: 20, category:, version:, - description: 'Some description', - custom_field_values: { wp_custom_field.id => 'Some wp cf text' }, - attachments: [build(:attachment, filename: 'work_package_attachment.pdf')]) + description: "Some description", + custom_field_values: { wp_custom_field.id => "Some wp cf text" }, + attachments: [build(:attachment, filename: "work_package_attachment.pdf")]) end let!(:wiki) { project.wiki } let!(:wiki_page) do create(:wiki_page, - title: 'Attached', + title: "Attached", wiki:, - attachments: [build(:attachment, container: nil, filename: 'wiki_page_attachment.pdf')]) + attachments: [build(:attachment, container: nil, filename: "wiki_page_attachment.pdf")]) end let(:parent_field) { FormFields::SelectFormField.new :parent } @@ -152,30 +152,30 @@ clear_performed_jobs end - context 'with correct project custom field activations' do + context "with correct project custom field activations" do before do original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! - find('.toolbar a', text: 'Copy').click + find(".toolbar a", text: "Copy").click expect(page).to have_text "Copy project \"#{project.name}\"" - fill_in 'Name', with: 'Copied project' + fill_in "Name", with: "Copied project" end - it 'enables the same project custom fields as activated on the source project if untouched' do + it "enables the same project custom fields as activated on the source project if untouched" do expect(project.project_custom_field_ids).to contain_exactly( project_custom_field.id, optional_project_custom_field.id, optional_project_custom_field_with_default.id ) - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -184,18 +184,18 @@ ) end - it 'does not disable optional project custom fields if explicitly set to blank' do + it "does not disable optional project custom fields if explicitly set to blank" do # Expand advanced settings - click_on 'Advanced settings' + click_on "Advanced settings" editor = Components::WysiwygEditor.new "[data-qa-field-name='customField#{optional_project_custom_field.id}']" editor.clear - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -204,18 +204,13 @@ ) # the optional custom field is activated, but set to blank value - expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq('') + expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq("") end - # TBD: Is this intended from a conceptial point of view? - # - # If not, I don't know how to change this behavior while keeping the behavior specified in the creation spec where - # optional custom fields are not activated if the value is set to blank in the form (which seems to be desired from - # a concpetional point of view) - it 'does enable project custom fields if set to blank in source project' do + it "does enable project custom fields if set to blank in source project" do project.update!(custom_field_values: { - optional_project_custom_field.id => '', - optional_project_custom_field_with_default.id => '' + optional_project_custom_field.id => "", + optional_project_custom_field_with_default.id => "" }) # the optional custom fields are activated, but set to blank values @@ -228,17 +223,17 @@ original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! - find('.toolbar a', text: 'Copy').click + find(".toolbar a", text: "Copy").click expect(page).to have_text "Copy project \"#{project.name}\"" - fill_in 'Name', with: 'Copied project' + fill_in "Name", with: "Copied project" - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -247,11 +242,11 @@ ) # the optional custom fields are activated, but set to blank values as seen in source project - expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq('') - expect(copied_project.custom_value_for(optional_project_custom_field_with_default).typed_value).to eq('') + expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq("") + expect(copied_project.custom_value_for(optional_project_custom_field_with_default).typed_value).to eq("") end - context 'with project custom fields with default values, which are disabled in source project' do + context "with project custom fields with default values, which are disabled in source project" do let!(:optional_boolean_project_custom_field_with_default) do create(:boolean_project_custom_field, is_required: false, default_value: true, project_custom_field_section:) end @@ -259,10 +254,10 @@ create(:boolean_project_custom_field, is_required: false, project_custom_field_section:) end let!(:optional_string_project_custom_field_with_default) do - create(:string_project_custom_field, is_required: false, default_value: 'bar', project_custom_field_section:) + create(:string_project_custom_field, is_required: false, default_value: "bar", project_custom_field_section:) end - it 'does not enable optional project custom fields with default values when not enabled in source project' do + it "does not enable optional project custom fields with default values when not enabled in source project" do # the optional boolean and string fields are not activated in the source project expect(project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -270,11 +265,11 @@ optional_project_custom_field_with_default.id ) - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") # the optional boolean and string fields are not activated in the target project, although they have a default value expect(copied_project.project_custom_field_ids).to contain_exactly( @@ -286,42 +281,42 @@ end end - context 'with correct handling of invisible values' do + context "with correct handling of invisible values" do let!(:invisible_field) do - create(:string_project_custom_field, name: 'Text for Admins only', + create(:string_project_custom_field, name: "Text for Admins only", visible: false, project_custom_field_section:, projects: [project]) end let!(:source_custom_value_for_invisible_field) do - create(:custom_value, customized: project, custom_field: invisible_field, value: 'foo') + create(:custom_value, customized: project, custom_field: invisible_field, value: "foo") end before do original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! - find('.toolbar a', text: 'Copy').click + find(".toolbar a", text: "Copy").click expect(page).to have_text "Copy project \"#{project.name}\"" - fill_in 'Name', with: 'Copied project' - click_on 'Advanced settings' + fill_in "Name", with: "Copied project" + click_on "Advanced settings" end - context 'with an admin user' do + context "with an admin user" do let(:user) { create(:admin) } - it 'shows invisible fields in the form and allows their activation' do - expect(page).to have_content 'Text for Admins only' + it "shows invisible fields in the form and allows their activation" do + expect(page).to have_content "Text for Admins only" # don't touch the source value - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -330,20 +325,19 @@ invisible_field.id ) - expect(copied_project.custom_value_for(invisible_field).typed_value).to eq('foo') + expect(copied_project.custom_value_for(invisible_field).typed_value).to eq("foo") end end - context 'with non-admin user' do - # TBD: Not sure if this is the desired behavior, but would be a bit tricky to change - it 'does not show invisible fields in the form and but still activates them' do - expect(page).to have_no_content 'Text for Admins only' + context "with non-admin user" do + it "does not show invisible fields in the form and but still activates them" do + expect(page).to have_no_content "Text for Admins only" - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") expect(copied_project.project_custom_field_ids).to contain_exactly( project_custom_field.id, @@ -355,28 +349,28 @@ end end - it 'copies projects and the associated objects' do + it "copies projects and the associated objects" do original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! - find('.toolbar a', text: 'Copy').click + find(".toolbar a", text: "Copy").click expect(page).to have_text "Copy project \"#{project.name}\"" - fill_in 'Name', with: 'Copied project' + fill_in "Name", with: "Copied project" # Expand advanced settings - click_on 'Advanced settings' + click_on "Advanced settings" # the value of the custom field should be preselected editor = Components::WysiwygEditor.new "[data-qa-field-name='customField#{project_custom_field.id}']" - editor.expect_value 'some text cf' + editor.expect_value "some text cf" - click_button 'Save' + click_button "Save" wait_for_copy_to_finish - copied_project = Project.find_by(name: 'Copied project') + copied_project = Project.find_by(name: "Copied project") expect(copied_project).to be_present @@ -392,16 +386,16 @@ # copies over the value of the custom field # has the parent of the original project editor = Components::WysiwygEditor.new "[data-qa-field-name='customField#{project_custom_field.id}']" - editor.expect_value 'some text cf' + editor.expect_value "some text cf" # has wp custom fields of original project active - copied_settings_page.visit_tab!('custom_fields') + copied_settings_page.visit_tab!("custom_fields") copied_settings_page.expect_wp_custom_field_active(wp_custom_field) copied_settings_page.expect_wp_custom_field_inactive(inactive_wp_custom_field) # has types of original project active - copied_settings_page.visit_tab!('types') + copied_settings_page.visit_tab!("types") active_types.each do |type| copied_settings_page.expect_type_active(type) @@ -411,10 +405,10 @@ # Expect wiki was copied expect(copied_project.wiki.pages.count).to eq(project.wiki.pages.count) - copied_page = copied_project.wiki.find_page 'Attached' + copied_page = copied_project.wiki.find_page "Attached" expect(copied_page).not_to be_nil expect(copied_page.attachments.map(&:filename)) - .to eq ['wiki_page_attachment.pdf'] + .to eq ["wiki_page_attachment.pdf"] # Expect ProjectStores and their FileLinks were copied expect(copied_project.project_storages.count).to eq(project.project_storages.count) @@ -437,8 +431,8 @@ expect(copied_work_package.description).to eql work_package.description expect(copied_work_package.category).to eql copied_project.categories.find_by(name: category.name) expect(copied_work_package.version).to eql copied_project.versions.find_by(name: version.name) - expect(copied_work_package.custom_value_attributes).to eql(wp_custom_field.id => 'Some wp cf text') - expect(copied_work_package.attachments.map(&:filename)).to eq ['work_package_attachment.pdf'] + expect(copied_work_package.custom_value_attributes).to eql(wp_custom_field.id => "Some wp cf text") + expect(copied_work_package.attachments.map(&:filename)).to eq ["work_package_attachment.pdf"] expect(ActionMailer::Base.deliveries.count).to eql(1) expect(ActionMailer::Base.deliveries.last.subject).to eql("Created project Copied project") @@ -446,10 +440,10 @@ end end - describe 'copying a set of ordered work packages' do + describe "copying a set of ordered work packages" do let(:user) { create(:admin) } let(:wp_table) { Pages::WorkPackagesTable.new project } - let(:copied_project) { Project.find_by(name: 'Copied project') } + let(:copied_project) { Project.find_by(name: "Copied project") } let(:copy_wp_table) { Pages::WorkPackagesTable.new copied_project } let(:project) { create(:project, types: [type]) } let(:type) { create(:type) } @@ -460,14 +454,14 @@ { type:, status:, project:, priority: } end - let(:parent1) { create(:work_package, default_params.merge(subject: 'Initial phase')) } - let(:child1_1) { create(:work_package, default_params.merge(parent: parent1, subject: 'Confirmation phase')) } - let(:child1_2) { create(:work_package, default_params.merge(parent: parent1, subject: 'Initiation')) } - let(:parent2) { create(:work_package, default_params.merge(subject: 'Execution')) } - let(:child2_1) { create(:work_package, default_params.merge(parent: parent2, subject: 'Define goal')) } - let(:child2_2) { create(:work_package, default_params.merge(parent: parent2, subject: 'Specify metrics')) } - let(:child2_3) { create(:work_package, default_params.merge(parent: parent2, subject: 'Prepare launch')) } - let(:child2_4) { create(:work_package, default_params.merge(parent: parent2, subject: 'Launch')) } + let(:parent1) { create(:work_package, default_params.merge(subject: "Initial phase")) } + let(:child1_1) { create(:work_package, default_params.merge(parent: parent1, subject: "Confirmation phase")) } + let(:child1_2) { create(:work_package, default_params.merge(parent: parent1, subject: "Initiation")) } + let(:parent2) { create(:work_package, default_params.merge(subject: "Execution")) } + let(:child2_1) { create(:work_package, default_params.merge(parent: parent2, subject: "Define goal")) } + let(:child2_2) { create(:work_package, default_params.merge(parent: parent2, subject: "Specify metrics")) } + let(:child2_3) { create(:work_package, default_params.merge(parent: parent2, subject: "Prepare launch")) } + let(:child2_4) { create(:work_package, default_params.merge(parent: parent2, subject: "Launch")) } let(:order) do [parent1, child1_1, child1_2, parent2, child2_1, child2_2, child2_3, child2_4] @@ -485,7 +479,7 @@ login_as user end - it 'copies them in the same order' do + it "copies them in the same order" do wp_table.visit! wp_table.expect_work_package_listed *order wp_table.expect_work_package_order *order @@ -493,13 +487,13 @@ original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! - find('.toolbar a', text: 'Copy').click + find(".toolbar a", text: "Copy").click - fill_in 'Name', with: 'Copied project' + fill_in "Name", with: "Copied project" - click_button 'Save' + click_button "Save" - expect(page).to have_text 'The job has been queued and will be processed shortly.' + expect(page).to have_text "The job has been queued and will be processed shortly." perform_enqueued_jobs @@ -513,7 +507,7 @@ end def wait_for_copy_to_finish - expect(page).to have_text 'The job has been queued and will be processed shortly.' + expect(page).to have_text "The job has been queued and will be processed shortly." # ensure all jobs are run especially emails which might be sent later on while perform_enqueued_jobs > 0 From 552c1c430b5c8fa4067cdb987053f25b06bd647e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 11:19:09 +0700 Subject: [PATCH 165/218] cleanup --- app/views/admin/settings/projects_settings/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/admin/settings/projects_settings/show.html.erb b/app/views/admin/settings/projects_settings/show.html.erb index 138198327e1d..25ad606a3a4f 100644 --- a/app/views/admin/settings/projects_settings/show.html.erb +++ b/app/views/admin/settings/projects_settings/show.html.erb @@ -102,4 +102,4 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_button_tag t(:button_save), class: '-primary -with-icon icon-checkmark' %> -<% end %> +<% end %> \ No newline at end of file From 3304f542676fa8adfa2da095e7d22e34069b714b Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 11:43:46 +0700 Subject: [PATCH 166/218] Fixed model relation definitions --- app/models/project_custom_field.rb | 5 +++-- app/models/project_custom_field_section.rb | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 91fd0bf2c6a2..bf83af7b4b05 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -27,8 +27,9 @@ #++ class ProjectCustomField < CustomField - belongs_to :project_custom_field_section, class_name: 'ProjectCustomFieldSection', foreign_key: :custom_field_section_id - has_many :project_custom_field_project_mappings, class_name: 'ProjectCustomFieldProjectMapping', foreign_key: :custom_field_id, + belongs_to :project_custom_field_section, class_name: "ProjectCustomFieldSection", foreign_key: :custom_field_section_id, + inverse_of: :custom_fields + has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :project_custom_field acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index 22a9ef3114e0..c88ef5f11b19 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -27,5 +27,6 @@ #++ class ProjectCustomFieldSection < CustomFieldSection - has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy + has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy, foreign_key: :custom_field_section_id, + inverse_of: :project_custom_field_section end From 0932dc5794227deb65029053974f589fc61ce0df Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 11:43:58 +0700 Subject: [PATCH 167/218] Fixed specs --- .../project_custom_fields/create_spec.rb | 58 +++++++++---------- .../admin/project_custom_fields/edit_spec.rb | 54 ++++++++--------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb index d5ec1245ee12..4f9b2fd53408 100644 --- a/spec/features/admin/project_custom_fields/create_spec.rb +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -26,80 +26,80 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative 'shared_context' +require "spec_helper" +require_relative "shared_context" -RSpec.describe 'Create project custom fields', :js do - include_context 'with seeded project custom fields' +RSpec.describe "Create project custom fields", :js do + include_context "with seeded project custom fields" - context 'with insufficient permissions' do - it 'is not accessible' do + context "with insufficient permissions" do + it "is not accessible" do login_as(non_admin) visit new_admin_settings_project_custom_field_path - expect(page).to have_text('You are not authorized to access this page.') + expect(page).to have_text("You are not authorized to access this page.") end end - context 'with sufficient permissions' do + context "with sufficient permissions" do before do login_as(admin) visit new_admin_settings_project_custom_field_path end - it 'shows a correct breadcrumb menu' do - within '#breadcrumb' do + it "shows a correct breadcrumb menu" do + within "#breadcrumb" do expect(page).to have_link("Administration") - expect(page).to have_link('Project attributes') - expect(page).to have_text('New attribute') + expect(page).to have_link("Project attributes") + expect(page).to have_text("New attribute") end end - it 'allows to create a new project custom field with an associated section' do + it "allows to create a new project custom field with an associated section" do # TODO: reuse specs for classic custom field form in order to test for other attribute settings - expect(page).to have_css('.PageHeader-title', text: 'New attribute') + expect(page).to have_css(".PageHeader-title", text: "New attribute") - fill_in('custom_field_name', with: 'New custom field') - select(section_for_select_fields.name, from: 'custom_field_custom_field_section_id') + fill_in("custom_field_name", with: "New custom field") + select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") - click_on('Save') + click_on("Save") # redirects to the overview page # the tab parameter is set as the redirect originates from the former custom field controller but does not have an effect - expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) + expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) - expect(page).to have_text('New custom field') + expect(page).to have_text("New custom field") latest_custom_field = ProjectCustomField.reorder(created_at: :asc).last - expect(latest_custom_field.name).to eq('New custom field') + expect(latest_custom_field.name).to eq("New custom field") expect(latest_custom_field.project_custom_field_section).to eq(section_for_select_fields) end - it 'allows to create a new project custom field with a prefilled section via url param' do + it "allows to create a new project custom field with a prefilled section via url param" do visit new_admin_settings_project_custom_field_path(custom_field_section_id: section_for_multi_select_fields.id) - fill_in('custom_field_name', with: 'New custom field') + fill_in("custom_field_name", with: "New custom field") - click_on('Save') + click_on("Save") # redirects to the overview page # the tab parameter is set as the redirect originates from the former custom field controller but does not have an effect - expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) + expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) latest_custom_field = ProjectCustomField.reorder(created_at: :asc).last - expect(latest_custom_field.name).to eq('New custom field') + expect(latest_custom_field.name).to eq("New custom field") expect(latest_custom_field.project_custom_field_section).to eq(section_for_multi_select_fields) end - it 'prevents creating a new project custom field with an empty name' do - click_on('Save') + it "prevents creating a new project custom field with an empty name" do + click_on("Save") - expect(page).to have_field 'custom_field_name', validation_message: 'Please fill in this field.' + expect(page).to have_field "custom_field_name", validation_message: "Please fill out this field." # expect no redirect - expect(page).to have_no_current_path(admin_settings_project_custom_fields_path(tab: 'ProjectCustomField')) + expect(page).to have_no_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) expect(page).to have_current_path(new_admin_settings_project_custom_field_path) end end diff --git a/spec/features/admin/project_custom_fields/edit_spec.rb b/spec/features/admin/project_custom_fields/edit_spec.rb index 3d6349874bb8..3ff90c44b8d0 100644 --- a/spec/features/admin/project_custom_fields/edit_spec.rb +++ b/spec/features/admin/project_custom_fields/edit_spec.rb @@ -26,69 +26,69 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative 'shared_context' +require "spec_helper" +require_relative "shared_context" -RSpec.describe 'Edit project custom fields', :js do - include_context 'with seeded project custom fields' +RSpec.describe "Edit project custom fields", :js do + include_context "with seeded project custom fields" - context 'with insufficient permissions' do - it 'is not accessible' do + context "with insufficient permissions" do + it "is not accessible" do login_as(non_admin) visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) - expect(page).to have_text('You are not authorized to access this page.') + expect(page).to have_text("You are not authorized to access this page.") end end - context 'with sufficient permissions' do + context "with sufficient permissions" do before do login_as(admin) visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) end - it 'shows a correct breadcrumb menu' do - within '#breadcrumb' do + it "shows a correct breadcrumb menu" do + within "#breadcrumb" do expect(page).to have_link("Administration") - expect(page).to have_link('Project attributes') + expect(page).to have_link("Project attributes") expect(page).to have_text(boolean_project_custom_field.name) end end - it 'allows to change basic attributes and the section of the project custom field' do + it "allows to change basic attributes and the section of the project custom field" do # TODO: reuse specs for classic custom field form in order to test for other attribute manipulations - expect(page).to have_css('.PageHeader-title', text: boolean_project_custom_field.name) + expect(page).to have_css(".PageHeader-title", text: boolean_project_custom_field.name) - fill_in('custom_field_name', with: 'Updated name') - select(section_for_select_fields.name, from: 'custom_field_custom_field_section_id') + fill_in("custom_field_name", with: "Updated name") + select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") - click_on('Save') + click_on("Save") - expect(page).to have_text('Successful update') + expect(page).to have_text("Successful update") - expect(page).to have_css('.PageHeader-title', text: 'Updated name') + expect(page).to have_css(".PageHeader-title", text: "Updated name") expect(boolean_project_custom_field.reload.name).to eq("Updated name") expect(boolean_project_custom_field.reload.project_custom_field_section).to eq(section_for_select_fields) - within '#breadcrumb' do + within "#breadcrumb" do expect(page).to have_link("Administration") - expect(page).to have_link('Project attributes') - expect(page).to have_text('Updated name') + expect(page).to have_link("Project attributes") + expect(page).to have_text("Updated name") end end - it 'prevents saving a project custom field with an empty name' do + it "prevents saving a project custom field with an empty name" do original_name = boolean_project_custom_field.name - fill_in('custom_field_name', with: '') - click_on('Save') + fill_in("custom_field_name", with: "") + click_on("Save") - expect(page).to have_field 'custom_field_name', validation_message: 'Please fill in this field.' + expect(page).to have_field "custom_field_name", validation_message: "Please fill out this field." - expect(page).to have_no_text('Successful update') + expect(page).to have_no_text("Successful update") - expect(page).to have_css('.PageHeader-title', text: original_name) + expect(page).to have_css(".PageHeader-title", text: original_name) expect(boolean_project_custom_field.reload.name).to eq(original_name) end end From bfd695b6ef95dd77ca1337f77e168627088772a9 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 11:58:06 +0700 Subject: [PATCH 168/218] Fixed customizable spec --- spec/models/projects/customizable_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 65e092bf6706..0c7a83d16be6 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -455,7 +455,7 @@ expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) .to contain_exactly(text_custom_field.id, bool_custom_field.id, hidden_custom_field.id) - expect(project.custom_value_for(hidden_custom_field)).to eq("hidden") + expect(project.custom_value_for(hidden_custom_field).typed_value).to eq("hidden") end end From ad8d2d59f9db2834e9b22cdbbbe7c99fc71df087 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Wed, 20 Mar 2024 06:16:18 +0100 Subject: [PATCH 169/218] Update app/services/projects/copy_service.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- app/services/projects/copy_service.rb | 33 +++------------------------ 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb index 48c0cefc94c3..dea7dff54097 100644 --- a/app/services/projects/copy_service.rb +++ b/app/services/projects/copy_service.rb @@ -81,41 +81,14 @@ def before_perform(params, service_call) end def after_perform(call) - disable_custom_fields_not_activated_in_source(call) - enable_custom_fields_not_activated_in_target(call) + copy_activated_custom_fields(call) super end - def disable_custom_fields_not_activated_in_source(call) - # TODO: seems a bit too hacky to me, find better solution - # remove custom fields from target project which are not activated in source project - # this is required in cases, when - # a custom field with a default value exists but is not activated in the source project - # this custom field is then not shown in the copy form (which is desired) - # but the custom field would be activated in the target project with its default value, - # which is desired in pure project creation context - # but in the copy context we clean them up here: - custom_fields_activated_in_source = source.project_custom_fields.pluck(:id) - custom_fields_activated_in_target = call.result.project_custom_fields.pluck(:id) - - custom_fields_to_disable = custom_fields_activated_in_target - custom_fields_activated_in_source - call.result.project_custom_field_project_mappings.where(custom_field_id: custom_fields_to_disable).destroy_all + def copy_activated_custom_fields(call) + call.result.project_custom_field_ids = source.project_custom_field_ids end - - def enable_custom_fields_not_activated_in_target(call) - # TODO: seems a bit too hacky to me, find better solution - # if custom fields in source project are activated but set to blank - # they would not be activated in the target project, - # which is desired in pure project creation context (-> as form fields are blank) - # but in the copy context we activate them here in order to have the same mapping as seen in the source project: - custom_fields_activated_in_source = source.project_custom_fields.pluck(:id) - custom_fields_activated_in_target = call.result.project_custom_fields.pluck(:id) - - custom_fields_to_enable = custom_fields_activated_in_source - custom_fields_activated_in_target - call.result.project_custom_fields << ProjectCustomField.where(id: custom_fields_to_enable) - end - def contract_options { copy_source: source, validate_model: true } end From 855e1b396c9e109eb60687a6a1570e2dfa77f119 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Wed, 20 Mar 2024 06:18:53 +0100 Subject: [PATCH 170/218] Update db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- ...d_project_custom_fields_in_all_projects.rb | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb b/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb index 9da66c00d584..9168d770fb80 100644 --- a/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb +++ b/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb @@ -1,14 +1,26 @@ class EnableRequiredProjectCustomFieldsInAllProjects < ActiveRecord::Migration[7.1] def up - required_project_custom_fields = ProjectCustomField.required.find_each.to_a + required_custom_field_ids = ProjectCustomField.required.ids - Project.includes(:project_custom_field_project_mappings).find_each do |project| - required_project_custom_fields.each do |pcf| - if project.project_custom_field_project_mappings.pluck(:custom_field_id).exclude?(pcf.id) - ProjectCustomFieldProjectMapping.create!(project_id: project.id, custom_field_id: pcf.id) + # Gather the custom_field_ids for every project, then add a new mapping + # of {project_id:, custom_field_id:} for every project that does not have + # the required required_custom_field_ids activated. + missing_custom_field_attributes = + Project + .includes(:project_custom_field_project_mappings) + .pluck(:id, 'project_custom_field_project_mappings.custom_field_id') + .group_by(&:first) + .transform_values { |values| values.map(&:last) } + .reduce([]) do |acc, (project_id, custom_field_ids)| + + missing_custom_field_ids = required_custom_field_ids - custom_field_ids + + acc + missing_custom_field_ids.map do |custom_field_id| + { project_id: , custom_field_id: } end end - end + + ProjectCustomFieldProjectMapping.insert_all!(missing_custom_field_attributes) end def down From fac00bb26342f8820a3005381656894356d8528d Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 12:54:59 +0700 Subject: [PATCH 171/218] validate presence of custom_field_section as suggested by @dombesz --- app/models/project_custom_field.rb | 2 ++ .../admin/project_custom_fields/create_spec.rb | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index bf83af7b4b05..d1aa6fe6077f 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -36,6 +36,8 @@ class ProjectCustomField < CustomField after_save :activate_required_field_in_all_projects + validates :custom_field_section_id, presence: true + def type_name :label_project_plural end diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb index 4f9b2fd53408..6e2e5e7bb274 100644 --- a/spec/features/admin/project_custom_fields/create_spec.rb +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -102,5 +102,20 @@ expect(page).to have_no_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) expect(page).to have_current_path(new_admin_settings_project_custom_field_path) end + + context "without any existing sections" do + before do + ProjectCustomFieldSection.destroy_all + visit new_admin_settings_project_custom_field_path + end + + it "prevents creating a new project custom field with an empty name" do + fill_in("custom_field_name", with: "New custom field") + + click_on("Save") + + expect(page).to have_text("Section can't be blank.") + end + end end end From 4913e4d9714b37ef8d4fabc15b1e803c5dd384ba Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 13:14:24 +0700 Subject: [PATCH 172/218] Spec adjustments as suggested by @dombesz --- .../overview_page/dialog/inputs_spec.rb | 242 ++++---- .../overview_page/dialog/update_spec.rb | 157 +++--- .../overview_page/dialog/validation_spec.rb | 288 +++------- .../overview_page/sidebar_spec.rb | 527 +++++++++--------- spec/models/projects/customizable_spec.rb | 1 - 5 files changed, 543 insertions(+), 672 deletions(-) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb index b95abbc8d325..0d4b5f53637d 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative '../shared_context' +require "spec_helper" +require_relative "../shared_context" -RSpec.describe 'Edit project custom fields on project overview page', :js do - include_context 'with seeded projects, members and project custom fields' +RSpec.describe "Edit project custom fields on project overview page", :js do + include_context "with seeded projects, members and project custom fields" let(:overview_page) { Pages::Projects::Show.new(project) } @@ -39,13 +39,13 @@ overview_page.visit_page end - describe 'with correct initialization and input behaviour' do - describe 'with input fields' do + describe "with correct initialization and input behaviour" do + describe "with input fields" do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field checkbox' do - it 'shows the correct value if given' do + shared_examples "a custom field checkbox" do + it "shows the correct value if given" do overview_page.open_edit_dialog_for_section(section) dialog.within_async_content(close_after_yield: true) do @@ -57,7 +57,7 @@ end end - it 'is unchecked if no value and no default value is given' do + it "is unchecked if no value and no default value is given" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -67,7 +67,7 @@ end end - it 'shows default value if no value is given' do + it "shows default value if no value is given" do custom_field.custom_values.destroy_all custom_field.update!(default_value: true) @@ -88,8 +88,8 @@ end end - shared_examples 'a custom field input' do - it 'shows the correct value if given' do + shared_examples "a custom field input" do + it "shows the correct value if given" do overview_page.open_edit_dialog_for_section(section) dialog.within_async_content(close_after_yield: true) do @@ -97,7 +97,7 @@ end end - it 'shows a blank input if no value or default value is given' do + it "shows a blank input if no value or default value is given" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -107,7 +107,7 @@ end end - it 'shows the default value if no value is given' do + it "shows the default value if no value is given" do custom_field.custom_values.destroy_all custom_field.update!(default_value:) @@ -119,8 +119,8 @@ end end - shared_examples 'a rich text custom field input' do - it 'shows the correct value if given' do + shared_examples "a rich text custom field input" do + it "shows the correct value if given" do overview_page.open_edit_dialog_for_section(section) dialog.within_async_content(close_after_yield: true) do @@ -128,7 +128,7 @@ end end - it 'shows a blank input if no value or default value is given' do + it "shows a blank input if no value or default value is given" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -138,7 +138,7 @@ end end - it 'shows the default value if no value is given' do + it "shows the default value if no value is given" do custom_field.custom_values.destroy_all custom_field.update!(default_value:) @@ -150,74 +150,72 @@ end end - describe 'with boolean CF' do + describe "with boolean CF" do let(:custom_field) { boolean_project_custom_field } - let(:default_value) { false } - let(:expected_blank_value) { false } let(:expected_initial_value) { true } - it_behaves_like 'a custom field checkbox' + it_behaves_like "a custom field checkbox" end - describe 'with string CF' do + describe "with string CF" do let(:custom_field) { string_project_custom_field } - let(:default_value) { 'Default value' } - let(:expected_blank_value) { '' } - let(:expected_initial_value) { 'Foo' } + let(:default_value) { "Default value" } + let(:expected_blank_value) { "" } + let(:expected_initial_value) { "Foo" } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with integer CF' do + describe "with integer CF" do let(:custom_field) { integer_project_custom_field } let(:default_value) { 789 } - let(:expected_blank_value) { '' } + let(:expected_blank_value) { "" } let(:expected_initial_value) { 123 } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with float CF' do + describe "with float CF" do let(:custom_field) { float_project_custom_field } let(:default_value) { 789.123 } - let(:expected_blank_value) { '' } + let(:expected_blank_value) { "" } let(:expected_initial_value) { 123.456 } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with date CF' do + describe "with date CF" do let(:custom_field) { date_project_custom_field } let(:default_value) { Date.new(2026, 1, 1) } - let(:expected_blank_value) { '' } + let(:expected_blank_value) { "" } let(:expected_initial_value) { Date.new(2024, 1, 1) } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with text CF' do + describe "with text CF" do let(:custom_field) { text_project_custom_field } let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - let(:default_value) { 'Default value' } - let(:expected_blank_value) { '' } + let(:default_value) { "Default value" } + let(:expected_blank_value) { "" } let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? - it_behaves_like 'a rich text custom field input' + it_behaves_like "a rich text custom field input" end end - describe 'with single select fields' do + describe "with single select fields" do let(:section) { section_for_select_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a autocomplete single select field' do - it 'shows the correct value if given' do + shared_examples "a autocomplete single select field" do + it "shows the correct value if given" do overview_page.open_edit_dialog_for_section(section) field.expect_selected(expected_initial_value) end - it 'shows a blank input if no value or default value is given' do + it "shows a blank input if no value or default value is given" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -225,7 +223,7 @@ field.expect_blank end - it 'filters the list based on the input' do + it "filters the list based on the input" do overview_page.open_edit_dialog_for_section(section) field.search(second_option) @@ -235,7 +233,7 @@ field.expect_no_option(third_option) end - it 'enables the user to select a single value from a list' do + it "enables the user to select a single value from a list" do overview_page.open_edit_dialog_for_section(section) field.search(second_option) @@ -250,7 +248,7 @@ field.expect_not_selected(second_option) end - it 'clears the input if clicked on the clear button' do + it "clears the input if clicked on the clear button" do overview_page.open_edit_dialog_for_section(section) field.clear @@ -259,7 +257,7 @@ end end - describe 'with single select list CF' do + describe "with single select list CF" do let(:custom_field) { list_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } @@ -269,9 +267,9 @@ let(:second_option) { custom_field.custom_options.second.value } let(:third_option) { custom_field.custom_options.third.value } - it_behaves_like 'a autocomplete single select field' + it_behaves_like "a autocomplete single select field" - it 'shows the default value if no value is given' do + it "shows the default value if no value is given" do custom_field.custom_values.destroy_all custom_field.custom_options.first.update!(default_value: true) @@ -282,7 +280,7 @@ end end - describe 'with single version select list CF' do + describe "with single version select list CF" do let(:custom_field) { version_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } @@ -292,35 +290,35 @@ let(:second_option) { second_version.name } let(:third_option) { third_version.name } - it_behaves_like 'a autocomplete single select field' + it_behaves_like "a autocomplete single select field" - describe 'with correct version scoping' do - context 'with a version on a different project' do + describe "with correct version scoping" do + context "with a version on a different project" do let!(:version_in_other_project) do - create(:version, name: 'Version 1 in other project', project: other_project) + create(:version, name: "Version 1 in other project", project: other_project) end - it 'shows only versions that are associated with this project' do + it "shows only versions that are associated with this project" do overview_page.open_edit_dialog_for_section(section) - field.search('Version 1') + field.search("Version 1") field.expect_option(first_version.name) field.expect_no_option(version_in_other_project.name) end end - context 'with a closed version' do - let!(:closed_version) { create(:version, name: 'Closed version', project:, status: 'closed') } + context "with a closed version" do + let!(:closed_version) { create(:version, name: "Closed version", project:, status: "closed") } before do custom_field.update(allow_non_open_versions:) end - context 'when non-open versions are not allowed' do + context "when non-open versions are not allowed" do let(:allow_non_open_versions) { false } - it 'does not shows closed version option' do + it "does not shows closed version option" do overview_page.open_edit_dialog_for_section(section) field.open_options @@ -329,10 +327,10 @@ end end - context 'when non-open versions are allowed' do + context "when non-open versions are allowed" do let(:allow_non_open_versions) { true } - it 'shows closed version option' do + it "shows closed version option" do overview_page.open_edit_dialog_for_section(section) field.open_options @@ -344,7 +342,7 @@ end end - describe 'with single user select list CF' do + describe "with single user select list CF" do let(:custom_field) { user_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } @@ -354,62 +352,62 @@ let(:second_option) { another_member_in_project.name } let(:third_option) { one_more_member_in_project.name } - it_behaves_like 'a autocomplete single select field' + it_behaves_like "a autocomplete single select field" - describe 'with correct user scoping' do + describe "with correct user scoping" do let!(:member_in_other_project) do create(:user, - firstname: 'Member 1', - lastname: 'In other Project', + firstname: "Member 1", + lastname: "In other Project", member_with_roles: { other_project => reader_role }) end - it 'shows only users that are members of the project' do + it "shows only users that are members of the project" do overview_page.open_edit_dialog_for_section(section) - field.search('Member 1') + field.search("Member 1") field.expect_option(member_in_project.name) field.expect_no_option(member_in_other_project.name) end end - describe 'with support for user groups' do + describe "with support for user groups" do let!(:member_in_other_project) do create(:user, - firstname: 'Member 1', - lastname: 'In other Project', + firstname: "Member 1", + lastname: "In other Project", member_with_roles: { other_project => reader_role }) end let!(:group) do - create(:group, name: 'Group 1 in project', + create(:group, name: "Group 1 in project", member_with_roles: { project => reader_role }) end let!(:group_in_other_project) do - create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + create(:group, name: "Group 1 in other project", members: [member_in_other_project], member_with_roles: { other_project => reader_role }) end - it 'shows only groups that are associated with this project' do + it "shows only groups that are associated with this project" do overview_page.open_edit_dialog_for_section(section) - field.search('Group 1') + field.search("Group 1") field.expect_option(group.name) field.expect_no_option(group_in_other_project.name) end end - describe 'with support for placeholder users' do + describe "with support for placeholder users" do let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder User', + create(:placeholder_user, name: "Placeholder User", member_with_roles: { project => reader_role }) end - it 'shows the placeholder user' do + it "shows the placeholder user" do overview_page.open_edit_dialog_for_section(section) - field.search('Placeholder User') + field.search("Placeholder User") field.expect_option(placeholder_user.name) end @@ -417,18 +415,18 @@ end end - describe 'with multi select fields' do + describe "with multi select fields" do let(:section) { section_for_multi_select_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a autocomplete multi select field' do - it 'shows the correct value if given' do + shared_examples "a autocomplete multi select field" do + it "shows the correct value if given" do overview_page.open_edit_dialog_for_section(section) field.expect_selected(*expected_initial_value) end - it 'shows a blank input if no value or default value is given' do + it "shows a blank input if no value or default value is given" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -436,7 +434,7 @@ field.expect_blank end - it 'filters the list based on the input' do + it "filters the list based on the input" do overview_page.open_edit_dialog_for_section(section) field.search(second_option) @@ -446,7 +444,7 @@ field.expect_no_option(third_option) end - it 'allows to select multiple values' do + it "allows to select multiple values" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -458,7 +456,7 @@ field.expect_selected(third_option) end - it 'allows to remove selected values' do + it "allows to remove selected values" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -472,7 +470,7 @@ field.expect_not_selected(third_option) end - it 'allows to remove all selected values at once' do + it "allows to remove all selected values at once" do custom_field.custom_values.destroy_all overview_page.open_edit_dialog_for_section(section) @@ -487,7 +485,7 @@ end end - describe 'with multi select list CF' do + describe "with multi select list CF" do let(:custom_field) { multi_list_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } @@ -497,9 +495,9 @@ let(:second_option) { custom_field.custom_options.second.value } let(:third_option) { custom_field.custom_options.third.value } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like "a autocomplete multi select field" - it 'shows the default value if no value is given' do + it "shows the default value if no value is given" do multi_list_project_custom_field.custom_values.destroy_all multi_list_project_custom_field.custom_options.first.update!(default_value: true) @@ -512,7 +510,7 @@ end end - describe 'with multi version select list CF' do + describe "with multi version select list CF" do let(:custom_field) { multi_version_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } @@ -522,9 +520,9 @@ let(:second_option) { second_version.name } let(:third_option) { third_version.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like "a autocomplete multi select field" - describe 'with correct version scoping' do + describe "with correct version scoping" do context "with a version on a different project" do let!(:version_in_other_project) do create(:version, name: "Version 1 in other project", project: other_project) @@ -574,7 +572,7 @@ end end - describe 'with multi user select list CF' do + describe "with multi user select list CF" do let(:custom_field) { multi_user_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } @@ -584,90 +582,90 @@ let(:second_option) { another_member_in_project.name } let(:third_option) { one_more_member_in_project.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like "a autocomplete multi select field" - describe 'with correct user scoping' do + describe "with correct user scoping" do let!(:member_in_other_project) do create(:user, - firstname: 'Member 1', - lastname: 'In other Project', + firstname: "Member 1", + lastname: "In other Project", member_with_roles: { other_project => reader_role }) end - it 'shows only users that are members of the project' do + it "shows only users that are members of the project" do overview_page.open_edit_dialog_for_section(section) - field.search('Member 1') + field.search("Member 1") field.expect_option(member_in_project.name) field.expect_no_option(member_in_other_project.name) end end - describe 'with support for user groups' do + describe "with support for user groups" do let!(:member_in_other_project) do create(:user, - firstname: 'Member 1', - lastname: 'In other Project', + firstname: "Member 1", + lastname: "In other Project", member_with_roles: { other_project => reader_role }) end let!(:group) do - create(:group, name: 'Group 1 in project', + create(:group, name: "Group 1 in project", member_with_roles: { project => reader_role }) end let!(:another_group) do - create(:group, name: 'Group 2 in project', + create(:group, name: "Group 2 in project", member_with_roles: { project => reader_role }) end let!(:group_in_other_project) do - create(:group, name: 'Group 1 in other project', members: [member_in_other_project], + create(:group, name: "Group 1 in other project", members: [member_in_other_project], member_with_roles: { other_project => reader_role }) end - it 'shows only groups that are associated with this project' do + it "shows only groups that are associated with this project" do overview_page.open_edit_dialog_for_section(section) - field.search('Group 1') + field.search("Group 1") field.expect_option(group.name) field.expect_no_option(group_in_other_project.name) end - it 'enables to select multiple user groups' do + it "enables to select multiple user groups" do overview_page.open_edit_dialog_for_section(section) - field.select_option('Group 1 in project') - field.select_option('Group 2 in project') + field.select_option("Group 1 in project") + field.select_option("Group 2 in project") - field.expect_selected('Group 1 in project') - field.expect_selected('Group 2 in project') + field.expect_selected("Group 1 in project") + field.expect_selected("Group 2 in project") end end - describe 'with support for placeholder users' do + describe "with support for placeholder users" do let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', + create(:placeholder_user, name: "Placeholder user", member_with_roles: { project => reader_role }) end let!(:another_placeholder_user) do - create(:placeholder_user, name: 'Another placeholder User', + create(:placeholder_user, name: "Another placeholder User", member_with_roles: { project => reader_role }) end let!(:placeholder_user_in_other_project) do - create(:placeholder_user, name: 'Placeholder user in other project', + create(:placeholder_user, name: "Placeholder user in other project", member_with_roles: { other_project => reader_role }) end - it 'shows only placeholder users from this project' do + it "shows only placeholder users from this project" do overview_page.open_edit_dialog_for_section(section) - field.search('Placeholder User') + field.search("Placeholder User") field.expect_option(placeholder_user.name) field.expect_option(another_placeholder_user.name) field.expect_no_option(placeholder_user_in_other_project.name) end - it 'enables to select multiple placeholder users' do + it "enables to select multiple placeholder users" do overview_page.open_edit_dialog_for_section(section) field.select_option(placeholder_user.name) diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb index 85d7d638f553..15f61de8bbe9 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative '../shared_context' +require "spec_helper" +require_relative "../shared_context" -RSpec.describe 'Edit project custom fields on project overview page', :js do - include_context 'with seeded projects, members and project custom fields' +RSpec.describe "Edit project custom fields on project overview page", :js do + include_context "with seeded projects, members and project custom fields" let(:overview_page) { Pages::Projects::Show.new(project) } @@ -39,19 +39,19 @@ overview_page.visit_page end - describe 'with correct updating behaviour' do - describe 'with input fields' do + describe "with correct updating behaviour" do + describe "with input fields" do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field checkbox' do - it 'sets the value to true if checked' do + shared_examples "a custom field checkbox" do + it "sets the value to true if checked" do custom_field.custom_values.destroy_all overview_page.visit_page overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content I18n.t('placeholders.default') + expect(page).to have_content I18n.t("placeholders.default") end overview_page.open_edit_dialog_for_section(section) @@ -66,7 +66,7 @@ end end - it 'sets the value to false if unchecked' do + it "sets the value to false if unchecked" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_content "Yes" end @@ -83,7 +83,7 @@ end end - it 'does not change the value if untouched' do + it "does not change the value if untouched" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_content "Yes" end @@ -101,14 +101,14 @@ end end - shared_examples 'a custom field input' do - it 'saves the value properly' do + shared_examples "a custom field input" do + it "saves the value properly" do custom_field.custom_values.destroy_all overview_page.visit_page overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content I18n.t('placeholders.default') + expect(page).to have_content I18n.t("placeholders.default") end overview_page.open_edit_dialog_for_section(section) @@ -123,7 +123,7 @@ end end - it 'does not change the value if untouched' do + it "does not change the value if untouched" do overview_page.visit_page overview_page.within_custom_field_container(custom_field) do @@ -142,26 +142,26 @@ end end - it 'removes the value properly' do + it "removes the value properly" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_content expected_initial_value end overview_page.open_edit_dialog_for_section(section) - field.fill_in(with: '') + field.fill_in(with: "") dialog.submit dialog.expect_closed overview_page.within_custom_field_container(custom_field) do - expect(page).to have_content I18n.t('placeholders.default') + expect(page).to have_content I18n.t("placeholders.default") end end end - shared_examples 'a rich text custom field input' do - it 'saves the value properly' do + shared_examples "a rich text custom field input" do + it "saves the value properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -182,7 +182,7 @@ end end - it 'does not change the value if untouched' do + it "does not change the value if untouched" do overview_page.visit_page overview_page.within_custom_field_container(custom_field) do @@ -201,14 +201,14 @@ end end - it 'removes the value properly' do + it "removes the value properly" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_text(expected_initial_value) end overview_page.open_edit_dialog_for_section(section) - field.set_value('') + field.set_value("") dialog.submit dialog.expect_closed @@ -219,71 +219,70 @@ end end - describe 'with boolean CF' do + describe "with boolean CF" do let(:custom_field) { boolean_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { true } - it_behaves_like 'a custom field checkbox' + it_behaves_like "a custom field checkbox" end - describe 'with string CF' do + describe "with string CF" do let(:custom_field) { string_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { 'Foo' } - let(:update_value) { 'Bar' } + let(:expected_initial_value) { "Foo" } + let(:update_value) { "Bar" } let(:expected_updated_value) { update_value } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with integer CF' do + describe "with integer CF" do let(:custom_field) { integer_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } let(:expected_initial_value) { 123 } let(:update_value) { 456 } let(:expected_updated_value) { update_value } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with float CF' do + describe "with float CF" do let(:custom_field) { float_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } let(:expected_initial_value) { 123.456 } let(:update_value) { 456.789 } let(:expected_updated_value) { update_value } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with date CF' do + describe "with date CF" do let(:custom_field) { date_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - let(:expected_initial_value) { '01/01/2024' } + let(:expected_initial_value) { "01/01/2024" } let(:update_value) { Date.new(2024, 1, 2) } - let(:expected_updated_value) { '01/02/2024' } + let(:expected_updated_value) { "01/02/2024" } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with text CF' do + describe "with text CF" do let(:custom_field) { text_project_custom_field } let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? let(:update_value) { "Dolor\n\nsit" } let(:expected_updated_value) { "Dolor\nsit" } - it_behaves_like 'a rich text custom field input' + it_behaves_like "a rich text custom field input" end end - describe 'with select fields' do + describe "with select fields" do let(:section) { section_for_select_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a select field' do - it 'saves the value properly' do + shared_examples "a select field" do + it "saves the value properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -304,7 +303,7 @@ end end - it 'does not change the value if untouched' do + it "does not change the value if untouched" do overview_page.visit_page overview_page.within_custom_field_container(custom_field) do @@ -324,7 +323,7 @@ end end - it 'removes the value properly' do + it "removes the value properly" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_text first_option end @@ -342,39 +341,39 @@ end end - describe 'with list CF' do + describe "with list CF" do let(:custom_field) { list_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:first_option) { custom_field.custom_options.first.value } - it_behaves_like 'a select field' + it_behaves_like "a select field" end - describe 'with version select CF' do + describe "with version select CF" do let(:custom_field) { version_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:first_option) { first_version.name } - it_behaves_like 'a select field' + it_behaves_like "a select field" end - describe 'with user select CF' do + describe "with user select CF" do let(:custom_field) { user_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:first_option) { member_in_project.name } - it_behaves_like 'a select field' + it_behaves_like "a select field" - describe 'with support for user groups' do + describe "with support for user groups" do let!(:group) do - create(:group, name: 'Group 1 in project', + create(:group, name: "Group 1 in project", member_with_roles: { project => reader_role }) end - it 'saves selected user group properly' do + it "saves selected user group properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -392,13 +391,13 @@ end end - describe 'with support for placeholder users' do + describe "with support for placeholder users" do let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', + create(:placeholder_user, name: "Placeholder user", member_with_roles: { project => reader_role }) end - it 'saves selected placeholer user properly' do + it "saves selected placeholer user properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -418,12 +417,12 @@ end end - describe 'with multi select fields' do + describe "with multi select fields" do let(:section) { section_for_multi_select_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a autocomplete multi select field' do - it 'saves single selected values properly' do + shared_examples "a autocomplete multi select field" do + it "saves single selected values properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -444,7 +443,7 @@ end end - it 'saves multi selected values properly' do + it "saves multi selected values properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -468,7 +467,7 @@ end end - it 'removes deselected values properly' do + it "removes deselected values properly" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_text first_option expect(page).to have_text second_option @@ -487,7 +486,7 @@ end end - it 'does not remove values when not touching the init values' do + it "does not remove values when not touching the init values" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_text first_option expect(page).to have_text second_option @@ -507,7 +506,7 @@ end end - it 'removes all values when clearing the input' do + it "removes all values when clearing the input" do overview_page.within_custom_field_container(custom_field) do expect(page).to have_text first_option expect(page).to have_text second_option @@ -526,7 +525,7 @@ end end - it 'adds values properly to init values' do + it "adds values properly to init values" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -562,46 +561,46 @@ end end - describe 'with multi select list CF' do + describe "with multi select list CF" do let(:custom_field) { multi_list_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:first_option) { custom_field.custom_options.first.value } let(:second_option) { custom_field.custom_options.second.value } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like "a autocomplete multi select field" end - describe 'with multi version select list CF' do + describe "with multi version select list CF" do let(:custom_field) { multi_version_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:first_option) { first_version.name } let(:second_option) { second_version.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like "a autocomplete multi select field" end - describe 'with multi user select list CF' do + describe "with multi user select list CF" do let(:custom_field) { multi_user_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:first_option) { member_in_project.name } let(:second_option) { another_member_in_project.name } - it_behaves_like 'a autocomplete multi select field' + it_behaves_like "a autocomplete multi select field" - describe 'with support for user groups' do + describe "with support for user groups" do let!(:group) do - create(:group, name: 'Group 1 in project', + create(:group, name: "Group 1 in project", member_with_roles: { project => reader_role }) end let!(:another_group) do - create(:group, name: 'Group 2 in project', + create(:group, name: "Group 2 in project", member_with_roles: { project => reader_role }) end - it 'saves selected user groups properly' do + it "saves selected user groups properly" do custom_field.custom_values.destroy_all overview_page.visit_page @@ -621,17 +620,17 @@ end end - describe 'with support for placeholder users' do + describe "with support for placeholder users" do let!(:placeholder_user) do - create(:placeholder_user, name: 'Placeholder user', + create(:placeholder_user, name: "Placeholder user", member_with_roles: { project => reader_role }) end let!(:another_placeholder_user) do - create(:placeholder_user, name: 'Another placeholder User', + create(:placeholder_user, name: "Another placeholder User", member_with_roles: { project => reader_role }) end - it 'shows only placeholder users from this project' do + it "shows only placeholder users from this project" do custom_field.custom_values.destroy_all overview_page.visit_page diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb index 460d7253ddf1..833cc8063f63 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative '../shared_context' +require "spec_helper" +require_relative "../shared_context" -RSpec.describe 'Edit project custom fields on project overview page', :js do - include_context 'with seeded projects, members and project custom fields' +RSpec.describe "Edit project custom fields on project overview page", :js do + include_context "with seeded projects, members and project custom fields" let(:overview_page) { Pages::Projects::Show.new(project) } @@ -39,12 +39,12 @@ overview_page.visit_page end - describe 'with correct validation behaviour' do - describe 'after validation' do + describe "with correct validation behaviour" do + describe "after validation" do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - it 'keeps showing only activated custom fields (tricky regression)' do + it "keeps showing only activated custom fields (tricky regression)" do custom_field = string_project_custom_field custom_field.update!(is_required: true) field = FormFields::Primerized::InputField.new(custom_field) @@ -54,38 +54,38 @@ dialog.within_async_content do containers = dialog.input_containers - expect(containers[0].text).to include('Boolean field') - expect(containers[1].text).to include('String field') - expect(containers[2].text).to include('Integer field') - expect(containers[3].text).to include('Float field') - expect(containers[4].text).to include('Date field') - expect(containers[5].text).to include('Text field') + expect(containers[0].text).to include("Boolean field") + expect(containers[1].text).to include("String field") + expect(containers[2].text).to include("Integer field") + expect(containers[3].text).to include("Float field") + expect(containers[4].text).to include("Date field") + expect(containers[5].text).to include("Text field") expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) end - field.fill_in(with: '') # this will trigger the validation + field.fill_in(with: "") # this will trigger the validation dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.blank')) + field.expect_error(I18n.t("activerecord.errors.messages.blank")) dialog.within_async_content do containers = dialog.input_containers - expect(containers[0].text).to include('Boolean field') - expect(containers[1].text).to include('String field') - expect(containers[2].text).to include('Integer field') - expect(containers[3].text).to include('Float field') - expect(containers[4].text).to include('Date field') - expect(containers[5].text).to include('Text field') + expect(containers[0].text).to include("Boolean field") + expect(containers[1].text).to include("String field") + expect(containers[2].text).to include("Integer field") + expect(containers[3].text).to include("Float field") + expect(containers[4].text).to include("Date field") + expect(containers[5].text).to include("Text field") expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) end end - describe 'does not loose the unpersisted values of the custom fields' do - context 'with input fields' do + describe "does not loose the unpersisted values of the custom fields" do + context "with input fields" do let(:section) { section_for_input_fields } let(:invalid_custom_field) { string_project_custom_field } @@ -93,32 +93,32 @@ let(:invalid_field) { FormFields::Primerized::InputField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::InputField.new(valid_custom_field) } - it 'keeps the value' do + it "keeps the value" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) - invalid_field.fill_in(with: '') - valid_field.fill_in(with: '123') + invalid_field.fill_in(with: "") + valid_field.fill_in(with: "123") dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) - invalid_field.expect_value('') - valid_field.expect_value('123') + invalid_field.expect_value("") + valid_field.expect_value("123") end end - context 'with select fields' do + context "with select fields" do let(:section) { section_for_select_fields } - context 'with version selected' do + context "with version selected" do let(:invalid_custom_field) { list_project_custom_field } let(:valid_custom_field) { version_project_custom_field } let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } - it 'keeps the value' do + it "keeps the value" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) @@ -127,20 +127,20 @@ dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank valid_field.expect_selected(third_version.name) end end - context 'with user selected' do + context "with user selected" do let(:invalid_custom_field) { list_project_custom_field } let(:valid_custom_field) { user_project_custom_field } let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } - it 'keeps the value' do + it "keeps the value" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) @@ -149,46 +149,46 @@ dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank valid_field.expect_selected(another_member_in_project.name) end end - context 'with list selected' do + context "with list selected" do let(:invalid_custom_field) { user_project_custom_field } let(:valid_custom_field) { list_project_custom_field } let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } - it 'keeps the value' do + it "keeps the value" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) invalid_field.clear - valid_field.select_option('Option 3') + valid_field.select_option("Option 3") dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank - valid_field.expect_selected('Option 3') + valid_field.expect_selected("Option 3") end end end - context 'with multi select fields' do + context "with multi select fields" do let(:section) { section_for_multi_select_fields } - context 'with multi version selected' do + context "with multi version selected" do let(:invalid_custom_field) { multi_list_project_custom_field } let(:valid_custom_field) { multi_version_project_custom_field } let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } - it 'keeps the values' do + it "keeps the values" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) @@ -198,20 +198,20 @@ dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank valid_field.expect_selected(first_version.name, third_version.name) end end - context 'with multi user selected' do + context "with multi user selected" do let(:invalid_custom_field) { multi_list_project_custom_field } let(:valid_custom_field) { multi_user_project_custom_field } let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } - it 'keeps the values' do + it "keeps the values" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) @@ -221,40 +221,40 @@ dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank valid_field.expect_selected(member_in_project.name, one_more_member_in_project.name) end end - context 'with multi list selected' do + context "with multi list selected" do let(:invalid_custom_field) { multi_user_project_custom_field } let(:valid_custom_field) { multi_list_project_custom_field } let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } - it 'keeps the value' do + it "keeps the value" do invalid_custom_field.update!(is_required: true) overview_page.open_edit_dialog_for_section(section) invalid_field.clear valid_field.clear - valid_field.select_option('Option 1', 'Option 3') + valid_field.select_option("Option 1", "Option 3") dialog.submit - invalid_field.expect_error(I18n.t('activerecord.errors.messages.blank')) + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank - valid_field.expect_selected('Option 1', 'Option 3') + valid_field.expect_selected("Option 1", "Option 3") end end end end end - describe 'editing multiple sections' do + describe "editing multiple sections" do let(:input_fields_dialog) do Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) end @@ -263,7 +263,7 @@ end let(:field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) } - it 'displays validation errors, when the previous section modal was canceled (Regression)' do + it "displays validation errors, when the previous section modal was canceled (Regression)" do list_project_custom_field.update!(is_required: true) list_project_custom_field.custom_values.destroy_all @@ -272,16 +272,16 @@ overview_page.open_edit_dialog_for_section(section_for_select_fields) select_fields_dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.blank')) + field.expect_error(I18n.t("activerecord.errors.messages.blank")) end end - describe 'with input fields' do + describe "with input fields" do let(:section) { section_for_input_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field input' do - it 'shows an error if the value is invalid' do + shared_examples "a custom field input" do + it "shows an error if the value is invalid" do custom_field.update!(is_required: true) custom_field.custom_values.destroy_all @@ -289,162 +289,54 @@ dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.blank')) + field.expect_error(I18n.t("activerecord.errors.messages.blank")) end end # boolean CFs can not be validated - describe 'with string CF' do + describe "with string CF" do let(:custom_field) { string_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 3) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: 'Foooo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 3, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: 'Fo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) - end - - it 'shows an error if the value does not match the regex' do - custom_field.update!(regexp: '^[A-Z]+$') - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: 'foo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.invalid')) - end + it_behaves_like "a custom field input" end - describe 'with integer CF' do + describe "with integer CF" do let(:custom_field) { integer_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 2) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '111') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 2)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 2, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '1') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 2)) - end + it_behaves_like "a custom field input" end - describe 'with float CF' do + describe "with float CF" do let(:custom_field) { float_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 4) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '1111.1') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 4)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 4, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.fill_in(with: '1.1') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 4)) - end + it_behaves_like "a custom field input" end - describe 'with date CF' do + describe "with date CF" do let(:custom_field) { date_project_custom_field } let(:field) { FormFields::Primerized::InputField.new(custom_field) } - it_behaves_like 'a custom field input' + it_behaves_like "a custom field input" end - describe 'with text CF' do + describe "with text CF" do let(:custom_field) { text_project_custom_field } let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } - it_behaves_like 'a custom field input' - - it 'shows an error if the value is too long' do - custom_field.update!(max_length: 3) - - overview_page.open_edit_dialog_for_section(section) - - field.set_value('Foooo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_long', count: 3)) - end - - it 'shows an error if the value is too short' do - custom_field.update!(min_length: 3, max_length: 5) - - overview_page.open_edit_dialog_for_section(section) - - field.set_value('Fo') - - dialog.submit - - field.expect_error(I18n.t('activerecord.errors.messages.too_short', count: 3)) - end + it_behaves_like "a custom field input" end end - describe 'with select fields' do + describe "with select fields" do let(:section) { section_for_select_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field select' do - it 'shows an error if the value is invalid' do + shared_examples "a custom field select" do + it "shows an error if the value is invalid" do custom_field.update!(is_required: true) custom_field.custom_values.destroy_all @@ -452,38 +344,38 @@ dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.blank')) + field.expect_error(I18n.t("activerecord.errors.messages.blank")) end end - describe 'with list CF' do + describe "with list CF" do let(:custom_field) { list_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - it_behaves_like 'a custom field select' + it_behaves_like "a custom field select" end - describe 'with version CF' do + describe "with version CF" do let(:custom_field) { version_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - it_behaves_like 'a custom field select' + it_behaves_like "a custom field select" end - describe 'with user CF' do + describe "with user CF" do let(:custom_field) { user_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - it_behaves_like 'a custom field select' + it_behaves_like "a custom field select" end end - describe 'with multi select fields' do + describe "with multi select fields" do let(:section) { section_for_multi_select_fields } let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } - shared_examples 'a custom field multi select' do - it 'shows an error if the value is invalid' do + shared_examples "a custom field multi select" do + it "shows an error if the value is invalid" do custom_field.update!(is_required: true) custom_field.custom_values.destroy_all @@ -491,29 +383,29 @@ dialog.submit - field.expect_error(I18n.t('activerecord.errors.messages.blank')) + field.expect_error(I18n.t("activerecord.errors.messages.blank")) end end - describe 'with multi list CF' do + describe "with multi list CF" do let(:custom_field) { multi_list_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - it_behaves_like 'a custom field multi select' + it_behaves_like "a custom field multi select" end - describe 'with multi version CF' do + describe "with multi version CF" do let(:custom_field) { multi_version_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - it_behaves_like 'a custom field multi select' + it_behaves_like "a custom field multi select" end - describe 'with multi user CF' do + describe "with multi user CF" do let(:custom_field) { multi_user_project_custom_field } let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } - it_behaves_like 'a custom field multi select' + it_behaves_like "a custom field multi select" end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 50f01061ca28..4eae8b7909e2 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative 'shared_context' +require "spec_helper" +require_relative "shared_context" -RSpec.describe 'Show project custom fields on project overview page', :js, :with_cuprite do - include_context 'with seeded projects, members and project custom fields' +RSpec.describe "Show project custom fields on project overview page", :js, :with_cuprite do + include_context "with seeded projects, members and project custom fields" let(:overview_page) { Pages::Projects::Show.new(project) } @@ -38,191 +38,174 @@ login_as admin end - it 'does show the project attributes sidebar' do + it "does show the project attributes sidebar" do overview_page.visit_page - within '.op-grid-page' do - expect(page).to have_css('#project-custom-fields-sidebar') + within ".op-grid-page" do + expect(page).to have_css("#project-custom-fields-sidebar") end end - describe 'with correct scoping' do - it 'shows enabled project custom fields in a sidebar grouped by section' do + describe "with correct order and scoping" do + it "shows the project custom field sections in the correct order" do overview_page.visit_page overview_page.within_async_loaded_sidebar do - expect(page).to have_css('.op-project-custom-field-section-container', count: 3) + sections = page.all(".op-project-custom-field-section-container") - overview_page.within_custom_field_section_container(section_for_input_fields) do - expect(page).to have_text 'Input fields' - - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'String field' - expect(page).to have_text 'Integer field' - expect(page).to have_text 'Float field' - expect(page).to have_text 'Date field' - expect(page).to have_text 'Text field' - end - - overview_page.within_custom_field_section_container(section_for_select_fields) do - expect(page).to have_text 'Select fields' - - expect(page).to have_text 'List field' - expect(page).to have_text 'Version field' - expect(page).to have_text 'User field' - end - - overview_page.within_custom_field_section_container(section_for_multi_select_fields) do - expect(page).to have_text 'Multi select fields' + expect(sections.size).to eq(3) - expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Multi version field' - expect(page).to have_text 'Multi user field' - end + expect(sections[0].text).to include("Input fields") + expect(sections[1].text).to include("Select fields") + expect(sections[2].text).to include("Multi select fields") end - end - it 'does not show project custom fields not enabled for this project in a sidebar' do - create(:string_project_custom_field, projects: [other_project], name: 'String field enabled for other project') + section_for_input_fields.move_to_bottom overview_page.visit_page overview_page.within_async_loaded_sidebar do - expect(page).to have_no_text 'String field enabled for other project' + sections = page.all(".op-project-custom-field-section-container") + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include("Select fields") + expect(sections[1].text).to include("Multi select fields") + expect(sections[2].text).to include("Input fields") end end - end - describe 'with correct order' do - it 'shows the project custom field sections in the correct order' do + it "shows the project custom fields in the correct order within the sections" do overview_page.visit_page overview_page.within_async_loaded_sidebar do - sections = page.all('.op-project-custom-field-section-container') + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all(".op-project-custom-field-container") - expect(sections.size).to eq(3) + expect(fields.size).to eq(6) - expect(sections[0].text).to include('Input fields') - expect(sections[1].text).to include('Select fields') - expect(sections[2].text).to include('Multi select fields') - end + expect(fields[0].text).to include("Boolean field") + expect(fields[1].text).to include("String field") + expect(fields[2].text).to include("Integer field") + expect(fields[3].text).to include("Float field") + expect(fields[4].text).to include("Date field") + expect(fields[5].text).to include("Text field") + end - section_for_input_fields.move_to_bottom + overview_page.within_custom_field_section_container(section_for_select_fields) do + fields = page.all(".op-project-custom-field-container") - overview_page.visit_page + expect(fields.size).to eq(3) - overview_page.within_async_loaded_sidebar do - sections = page.all('.op-project-custom-field-section-container') + expect(fields[0].text).to include("List field") + expect(fields[1].text).to include("Version field") + expect(fields[2].text).to include("User field") + end - expect(sections.size).to eq(3) + overview_page.within_custom_field_section_container(section_for_multi_select_fields) do + fields = page.all(".op-project-custom-field-container") - expect(sections[0].text).to include('Select fields') - expect(sections[1].text).to include('Multi select fields') - expect(sections[2].text).to include('Input fields') + expect(fields.size).to eq(3) + + expect(fields[0].text).to include("Multi list field") + expect(fields[1].text).to include("Multi version field") + expect(fields[2].text).to include("Multi user field") + end end - end - it 'shows the project custom fields in the correct order within the sections' do + string_project_custom_field.move_to_bottom + overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_section_container(section_for_input_fields) do - fields = page.all('.op-project-custom-field-container') + fields = page.all(".op-project-custom-field-container") expect(fields.size).to eq(6) - expect(fields[0].text).to include('Boolean field') - expect(fields[1].text).to include('String field') - expect(fields[2].text).to include('Integer field') - expect(fields[3].text).to include('Float field') - expect(fields[4].text).to include('Date field') - expect(fields[5].text).to include('Text field') + expect(fields[0].text).to include("Boolean field") + expect(fields[1].text).to include("Integer field") + expect(fields[2].text).to include("Float field") + expect(fields[3].text).to include("Date field") + expect(fields[4].text).to include("Text field") + expect(fields[5].text).to include("String field") end end + end - string_project_custom_field.move_to_bottom + it "does not show project custom fields not enabled for this project in a sidebar" do + create(:string_project_custom_field, projects: [other_project], name: "String field enabled for other project") overview_page.visit_page overview_page.within_async_loaded_sidebar do - overview_page.within_custom_field_section_container(section_for_input_fields) do - fields = page.all('.op-project-custom-field-container') - - expect(fields.size).to eq(6) - - expect(fields[0].text).to include('Boolean field') - expect(fields[1].text).to include('Integer field') - expect(fields[2].text).to include('Float field') - expect(fields[3].text).to include('Date field') - expect(fields[4].text).to include('Text field') - expect(fields[5].text).to include('String field') - end + expect(page).to have_no_text "String field enabled for other project" end end end - describe 'with correct values' do - describe 'with boolean CF' do + describe "with correct values" do + describe "with boolean CF" do # it_behaves_like 'a project custom field' do # let(subject) { boolean_project_custom_field } # end - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'Yes' + expect(page).to have_text "Boolean field" + expect(page).to have_text "Yes" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do # A boolean cannot be completely unset via UI, only toggle between true and false, no blank value possible before do boolean_project_custom_field.custom_values.where(customized: project).first.update!(value: false) end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text 'No' + expect(page).to have_text "Boolean field" + expect(page).to have_text "No" end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do boolean_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Boolean field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value for the project custom field if no value given' do + it "does not show the default value for the project custom field if no value given" do boolean_project_custom_field.update!(default_value: true) overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Boolean field" + expect(page).to have_text I18n.t("placeholders.default") end end @@ -232,295 +215,295 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do - expect(page).to have_text 'Boolean field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Boolean field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with string CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with string CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text 'Foo' + expect(page).to have_text "String field" + expect(page).to have_text "Foo" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - string_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + string_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "String field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do string_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "String field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value for the project custom field if no value given' do - string_project_custom_field.update!(default_value: 'Bar') + it "does not show the default value for the project custom field if no value given" do + string_project_custom_field.update!(default_value: "Bar") overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do - expect(page).to have_text 'String field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "String field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with integer CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with integer CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text '123' + expect(page).to have_text "Integer field" + expect(page).to have_text "123" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - integer_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + integer_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Integer field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do integer_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Integer field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value for the project custom field if no value given' do + it "does not show the default value for the project custom field if no value given" do integer_project_custom_field.update!(default_value: 456) overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do - expect(page).to have_text 'Integer field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Integer field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with date CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with date CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text '01/01/2024' + expect(page).to have_text "Date field" + expect(page).to have_text "01/01/2024" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - date_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + date_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Date field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do date_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Date field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value for the project custom field if no value given' do + it "does not show the default value for the project custom field if no value given" do date_project_custom_field.update!(default_value: Date.new(2024, 2, 2)) overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do - expect(page).to have_text 'Date field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Date field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with float CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with float CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text '123.456' + expect(page).to have_text "Float field" + expect(page).to have_text "123.456" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - float_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + float_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Float field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do float_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Float field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'dies not show the default value for the project custom field if no value given' do + it "dies not show the default value for the project custom field if no value given" do float_project_custom_field.update!(default_value: 456.789) overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do - expect(page).to have_text 'Float field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Float field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with text CF' do - describe 'with value set by user' do - context 'with a value that is shorter than 100 characters' do - it 'shows the correct value for the project custom field if given without truncation and dialog button' do + describe "with text CF" do + describe "with value set by user" do + context "with a value that is shorter than 100 characters" do + it "shows the correct value for the project custom field if given without truncation and dialog button" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' + expect(page).to have_text "Text field" expect(page).to have_text "Lorem\nipsum" end end end end - context 'with a value that is longer than 100 characters' do + context "with a value that is longer than 100 characters" do before do - text_project_custom_field.custom_values.where(customized: project).first.update!(value: 'a' * 101) + text_project_custom_field.custom_values.where(customized: project).first.update!(value: "a" * 101) end - it 'shows the correct value for the project custom field if given with truncation and dialog button' do + it "shows the correct value for the project custom field if given with truncation and dialog button" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' + expect(page).to have_text "Text field" expect(page).to have_text ("#{'a' * 97}...") - expect(page).to have_text 'Expand' + expect(page).to have_text "Expand" - click_on 'Expand' + click_on "Expand" - within 'dialog' do - expect(page).to have_text 'a' * 101 + within "dialog" do + expect(page).to have_text "a" * 101 end end end @@ -528,254 +511,254 @@ end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - text_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + text_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Text field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do text_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Text field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value for the project custom field if no value given' do - text_project_custom_field.update!(default_value: 'Dolor sit amet') + it "does not show the default value for the project custom field if no value given" do + text_project_custom_field.update!(default_value: "Dolor sit amet") overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do - expect(page).to have_text 'Text field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Text field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with list CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with list CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text 'Option 1' + expect(page).to have_text "List field" + expect(page).to have_text "Option 1" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - list_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + list_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "List field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do list_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "List field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value for the project custom field if no value given' do + it "does not show the default value for the project custom field if no value given" do list_project_custom_field.custom_options.first.update!(default_value: true) overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do - expect(page).to have_text 'List field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "List field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with version CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with version CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do - expect(page).to have_text 'Version field' - expect(page).to have_text 'Version 1' + expect(page).to have_text "Version field" + expect(page).to have_text "Version 1" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - version_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + version_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do - expect(page).to have_text 'Version field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Version field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do version_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do - expect(page).to have_text 'Version field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Version field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with user CF' do - describe 'with value set by user' do - it 'shows the correct value for the project custom field if given' do + describe "with user CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do - expect(page).to have_text 'User field' - expect(page).to have_css('opce-principal') - expect(page).to have_text 'Member 1 In Project' + expect(page).to have_text "User field" + expect(page).to have_css("opce-principal") + expect(page).to have_text "Member 1 In Project" end end end end - describe 'with value unset by user' do + describe "with value unset by user" do before do - user_project_custom_field.custom_values.where(customized: project).first.update!(value: '') + user_project_custom_field.custom_values.where(customized: project).first.update!(value: "") end - it 'shows the correct value for the project custom field if given' do + it "shows the correct value for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do - expect(page).to have_text 'User field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "User field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do user_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do - expect(page).to have_text 'User field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "User field" + expect(page).to have_text I18n.t("placeholders.default") end end end end - describe 'with support for user groups' do + describe "with support for user groups" do # TODO end - describe 'with support for user placeholders' do + describe "with support for user placeholders" do # TODO end end - describe 'with multi list CF' do - describe 'with value set by user' do - it 'shows the correct values for the project custom field if given' do + describe "with multi list CF" do + describe "with value set by user" do + it "shows the correct values for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_text 'Multi list field' - expect(page).to have_text 'Option 1, Option 2' + expect(page).to have_text "Multi list field" + expect(page).to have_text "Option 1, Option 2" end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do multi_list_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_text 'Multi list field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Multi list field" + expect(page).to have_text I18n.t("placeholders.default") end end end - it 'does not show the default value(s) for the project custom field if no value given' do + it "does not show the default value(s) for the project custom field if no value given" do multi_list_project_custom_field.custom_options.first.update!(default_value: true) multi_list_project_custom_field.custom_options.second.update!(default_value: true) @@ -783,74 +766,74 @@ overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do - expect(page).to have_text 'Multi list field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Multi list field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with multi version CF' do - describe 'with value set by user' do - it 'shows the correct values for the project custom field if given' do + describe "with multi version CF" do + describe "with value set by user" do + it "shows the correct values for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_version_project_custom_field) do - expect(page).to have_text 'Multi version field' - expect(page).to have_text 'Version 1, Version 2' + expect(page).to have_text "Multi version field" + expect(page).to have_text "Version 1, Version 2" end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do multi_version_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_version_project_custom_field) do - expect(page).to have_text 'Multi version field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Multi version field" + expect(page).to have_text I18n.t("placeholders.default") end end end end end - describe 'with multi user CF' do - describe 'with value set by user' do - it 'shows the correct values for the project custom field if given' do + describe "with multi user CF" do + describe "with value set by user" do + it "shows the correct values for the project custom field if given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do - expect(page).to have_text 'Multi user field' - expect(page).to have_css 'opce-principal', count: 2 - expect(page).to have_text 'Member 1 In Project' - expect(page).to have_text 'Member 2 In Project' + expect(page).to have_text "Multi user field" + expect(page).to have_css "opce-principal", count: 2 + expect(page).to have_text "Member 1 In Project" + expect(page).to have_text "Member 2 In Project" end end end end - describe 'with no value set by user' do + describe "with no value set by user" do before do multi_user_project_custom_field.custom_values.where(customized: project).destroy_all end - it 'shows an N/A text for the project custom field if no value given' do + it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page overview_page.within_async_loaded_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do - expect(page).to have_text 'Multi user field' - expect(page).to have_text I18n.t('placeholders.default') + expect(page).to have_text "Multi user field" + expect(page).to have_text I18n.t("placeholders.default") end end end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index 0c7a83d16be6..6dc4d64fe222 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -96,7 +96,6 @@ context "when custom fields are mapped to this project" do before do project.project_custom_fields << [text_custom_field, bool_custom_field] - project.reload # TODO: why is this necessary? end it "#custom_field_values returns a hash of mapped custom fields with nil values" do From a48248ba37ef3761833fd219b67d714706be47be Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 13:38:31 +0700 Subject: [PATCH 173/218] Adjusted filter approach as suggested by @dombesz --- app/components/_index.sass | 1 + .../project_custom_field_sections/index_component.sass | 3 +++ .../project-custom-fields-mapping-filter.controller.ts | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 app/components/projects/settings/project_custom_field_sections/index_component.sass diff --git a/app/components/_index.sass b/app/components/_index.sass index 1c262068b87b..28d1d6ec9cb8 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -2,3 +2,4 @@ @import "work_packages/share/invite_user_form_component" @import "open_project/common/attribute_component" @import "filters_component" +@import "projects/settings/project_custom_field_sections/index_component" diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.sass b/app/components/projects/settings/project_custom_field_sections/index_component.sass new file mode 100644 index 000000000000..c91546101fe3 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/index_component.sass @@ -0,0 +1,3 @@ +.Box-row + &.hidden-by-filter + display: none \ No newline at end of file diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts index 2f6fbc283289..54a5a520bf1e 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts @@ -66,9 +66,9 @@ export default class ProjectCustomFieldsMappingFilterController extends Controll const text = item.textContent?.toLowerCase(); if (text?.includes(query)) { - (item as HTMLElement).style.display = 'block'; + (item as HTMLElement).classList.remove('hidden-by-filter'); } else { - (item as HTMLElement).style.display = 'none'; + (item as HTMLElement).classList.add('hidden-by-filter'); } }); } From 2ace8289ad69c0493009a1d55c2f51da41d64c60 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:36:48 +0200 Subject: [PATCH 174/218] Move ProjectCustomField section validation to the object instead of the field. --- app/models/project_custom_field.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index d1aa6fe6077f..bb3775efaa9f 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -36,7 +36,7 @@ class ProjectCustomField < CustomField after_save :activate_required_field_in_all_projects - validates :custom_field_section_id, presence: true + validates :project_custom_field_section, presence: true def type_name :label_project_plural From 451df6b9f82d8abd9a3cd18ded1c7643fc7095b4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Mar 2024 20:18:03 +0700 Subject: [PATCH 175/218] quick fix failing spec due to changed error message --- spec/features/admin/project_custom_fields/create_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb index 6e2e5e7bb274..33e43f63012b 100644 --- a/spec/features/admin/project_custom_fields/create_spec.rb +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -114,7 +114,7 @@ click_on("Save") - expect(page).to have_text("Section can't be blank.") + expect(page).to have_text("section can't be blank.") end end end From 4d93ce97f98dba005ba5740d44d6a1691b68cc6b Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 20 Mar 2024 21:13:49 +0200 Subject: [PATCH 176/218] Display error messages for missing custom field section on creating project custom fields. --- app/models/project_custom_field.rb | 2 +- app/views/custom_fields/_form.html.erb | 5 +++-- spec/contracts/custom_fields/create_contract_spec.rb | 2 +- spec/contracts/custom_fields/update_contract_spec.rb | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index bb3775efaa9f..d1aa6fe6077f 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -36,7 +36,7 @@ class ProjectCustomField < CustomField after_save :activate_required_field_in_all_projects - validates :project_custom_field_section, presence: true + validates :custom_field_section_id, presence: true def type_name :label_project_plural diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index c8d6cd1a10ac..16dc232c5ac9 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -31,10 +31,11 @@ See COPYRIGHT and LICENSE files for more details. <%= f.text_field :name, required: true, container_class: '-middle' %>
<% if @custom_field.type == 'ProjectCustomField' %> -
+
<%= f.select :custom_field_section_id, ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, - { container_class: '-slim' } + { container_class: '-slim' }, + required: true %>
<% end %> diff --git a/spec/contracts/custom_fields/create_contract_spec.rb b/spec/contracts/custom_fields/create_contract_spec.rb index 61702e115ad1..1675b253e1ef 100644 --- a/spec/contracts/custom_fields/create_contract_spec.rb +++ b/spec/contracts/custom_fields/create_contract_spec.rb @@ -32,7 +32,7 @@ RSpec.describe CustomFields::CreateContract do include_context 'ModelContract shared context' - let(:cf) { build(:project_custom_field) } + let(:cf) { build_stubbed(:project_custom_field) } let(:contract) do described_class.new(cf, current_user, options: {}) end diff --git a/spec/contracts/custom_fields/update_contract_spec.rb b/spec/contracts/custom_fields/update_contract_spec.rb index 9ff30cdb5718..9f872d6ffb26 100644 --- a/spec/contracts/custom_fields/update_contract_spec.rb +++ b/spec/contracts/custom_fields/update_contract_spec.rb @@ -32,7 +32,7 @@ RSpec.describe CustomFields::UpdateContract do include_context 'ModelContract shared context' - let(:cf) { build(:project_custom_field) } + let(:cf) { build_stubbed(:project_custom_field) } let(:contract) do described_class.new(cf, current_user) end From abbaa50ced935204e3474eb70e1116bc0bf49d28 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 20 Mar 2024 22:42:22 +0200 Subject: [PATCH 177/218] Remove N+1 queries from CustomFieldSection rendering --- .../index_component.html.erb | 2 +- .../index_component.rb | 4 +++ .../show_component.rb | 30 ++++++++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/index_component.html.erb b/app/components/settings/project_custom_field_sections/index_component.html.erb index 68f40c5ed86d..2d26ddc97e84 100644 --- a/app/components/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/settings/project_custom_field_sections/index_component.html.erb @@ -6,7 +6,7 @@ flex.with_row( data: draggable_item_config(section) ) do - render(Settings::ProjectCustomFieldSections::ShowComponent.new(project_custom_field_section: section)) + render(Settings::ProjectCustomFieldSections::ShowComponent.new(project_custom_field_section: section, first_and_last:)) end end end diff --git a/app/components/settings/project_custom_field_sections/index_component.rb b/app/components/settings/project_custom_field_sections/index_component.rb index c4e6e2098d04..7f4d7bdff1a4 100644 --- a/app/components/settings/project_custom_field_sections/index_component.rb +++ b/app/components/settings/project_custom_field_sections/index_component.rb @@ -39,6 +39,10 @@ def initialize(project_custom_field_sections:) @project_custom_field_sections = project_custom_field_sections end + def first_and_last + [@project_custom_field_sections.first, @project_custom_field_sections.last] + end + private def wrapper_data_attributes diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index 1f3ed82c3925..76fa3c16a738 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -33,11 +33,12 @@ class ShowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project_custom_field_section:) + def initialize(project_custom_field_section:, first_and_last: []) super @project_custom_field_section = project_custom_field_section @project_custom_fields = project_custom_field_section.custom_fields + @first_and_last = first_and_last end private @@ -64,17 +65,12 @@ def draggable_item_config(project_custom_field) end def move_actions(menu) - # TODO: these methods trigger database queries for each section displayed - # it would be nice if can eager load this information - first_in_list = @project_custom_field_section.first? - last_in_list = @project_custom_field_section.last? - - unless first_in_list + unless first? move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") end - unless last_in_list + unless last? move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), @@ -122,6 +118,24 @@ def delete_action_item(menu) item.with_leading_visual_icon(icon: :trash) end end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @project_custom_field_section + else + @project_custom_field_section.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @project_custom_field_section + else + @project_custom_field_section.last? + end + end end end end From c68d1a5c3464370adce10c815b61a507b3c9a84c Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 20 Mar 2024 23:20:02 +0200 Subject: [PATCH 178/218] Remove N+1 queries from the ProjectCustomFields rendering inside the custom field sections --- .../custom_field_row_component.rb | 30 ++++++++++++++----- .../show_component.html.erb | 8 ++++- .../component_streams.rb | 14 --------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index 4a5ad54384df..1b203dabce3b 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -33,10 +33,11 @@ class CustomFieldRowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project_custom_field:) + def initialize(project_custom_field:, first_and_last:) super @project_custom_field = project_custom_field + @first_and_last = first_and_last end private @@ -50,17 +51,12 @@ def edit_action_item(menu) end def move_actions(menu) - # TODO: these methods trigger database queries for each custom field displayed - # it would be nice if can eager load this information - first_in_list = @project_custom_field.first? - last_in_list = @project_custom_field.last? - - unless first_in_list + unless first? move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") end - unless last_in_list + unless last? move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), @@ -99,6 +95,24 @@ def project_count_text "#{project_count} #{t('label_project_plural')}" end end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @project_custom_field + else + @project_custom_field.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @project_custom_field + else + @project_custom_field.last? + end + end end end end diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index a7878c814935..ea991c887388 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -59,11 +59,17 @@ end end else + first_and_last = [@project_custom_fields.first, @project_custom_fields.last] @project_custom_fields.each do |project_custom_field| component.with_row( data: draggable_item_config(project_custom_field) ) do - render(Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new(project_custom_field:)) + render( + ::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( + project_custom_field:, + first_and_last: + ) + ) end end end diff --git a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb index 8bb0724cf735..1e1f1cd5f2d2 100644 --- a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb @@ -48,20 +48,6 @@ def update_sections_via_turbo_stream( ) ) end - - def update_custom_field_row_via_turbo_stream( - project: @project, - project_custom_field: @project_custom_field, - project_custom_field_project_mappings: @project_custom_field_project_mappings - ) - update_via_turbo_stream( - component: ::Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( - project:, - project_custom_field:, - project_custom_field_project_mappings: - ) - ) - end end end end From 15132d07c07221f9ad61a5bedf1d74f85ac93482 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 21 Mar 2024 11:21:02 +0700 Subject: [PATCH 179/218] fixed spec due to changed validation approach --- spec/features/admin/project_custom_fields/create_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb index 33e43f63012b..d6229369c93f 100644 --- a/spec/features/admin/project_custom_fields/create_spec.rb +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -114,7 +114,8 @@ click_on("Save") - expect(page).to have_text("section can't be blank.") + expect(page).to have_field "custom_field_custom_field_section_id", + validation_message: "Please select an item in the list." end end end From e492bf80b9187b1ef8889f7018f238440f9fb704 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 21 Mar 2024 11:10:52 +0100 Subject: [PATCH 180/218] Add unit test for appsignal fix in PR #15066 --- spec/lib/open_project/appsignal_spec.rb | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 spec/lib/open_project/appsignal_spec.rb diff --git a/spec/lib/open_project/appsignal_spec.rb b/spec/lib/open_project/appsignal_spec.rb new file mode 100644 index 000000000000..3cf4c8d4f1a5 --- /dev/null +++ b/spec/lib/open_project/appsignal_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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" +require "appsignal" + +RSpec.describe OpenProject::Appsignal do + describe ".exception_handler" do + let(:exception) { StandardError.new("I am a fake exception") } + + before do + allow(Appsignal).to receive(:active?).and_return(true) + allow(Appsignal).to receive(:send_error) + end + + it "does nothing if there is no exception in the log context" do + described_class.exception_handler("message") + expect(Appsignal).not_to have_received(:send_error) + end + + it "stores the exception in current appsignal transaction if one is available" do + transaction = Appsignal::Transaction.create( + SecureRandom.uuid, + Appsignal::Transaction::BACKGROUND_JOB, + Appsignal::Transaction::GenericRequest.new({}) + ) + allow(transaction).to receive(:set_error) + described_class.exception_handler("message", exception:) + + expect(transaction).to have_received(:set_error).with(exception) + expect(Appsignal).not_to have_received(:send_error) + ensure + Appsignal::Transaction.complete_current! + end + + it "sends an error through Appsignal if no current appsignal transaction available" do + allow(Appsignal).to receive(:send_error) + described_class.exception_handler("message", exception:) + + expect(Appsignal).to have_received(:send_error).with(exception) + end + end +end From 0386fcebae1df5873a52d2aa9c3b1f8664a47487 Mon Sep 17 00:00:00 2001 From: Pavel Balashou Date: Thu, 21 Mar 2024 12:00:13 +0100 Subject: [PATCH 181/218] Set operation and request httpx timeouts. --- config/constants/settings/definition.rb | 14 ++++++++++++++ lib/open_project.rb | 2 ++ 2 files changed, 16 insertions(+) diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 5fce328a34e9..7bc12878aa71 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -742,6 +742,20 @@ class Definition allowed: (0..), default: 3 }, + httpx_operation_timeout: { + description: '', + format: :float, + writable: false, + allowed: (0..), + default: 10 + }, + httpx_request_timeout: { + description: '', + format: :float, + writable: false, + allowed: (0..), + default: 10 + }, httpx_read_timeout: { description: '', format: :float, diff --git a/lib/open_project.rb b/lib/open_project.rb index f35181821378..e154bb70a5c6 100644 --- a/lib/open_project.rb +++ b/lib/open_project.rb @@ -58,6 +58,8 @@ def self.httpx .with( timeout: { connect_timeout: OpenProject::Configuration.httpx_connect_timeout, + operation_timeout: OpenProject::Configuration.httpx_operation_timeout, + request_timeout: OpenProject::Configuration.httpx_request_timeout, write_timeout: OpenProject::Configuration.httpx_write_timeout, read_timeout: OpenProject::Configuration.httpx_read_timeout, keep_alive_timeout: OpenProject::Configuration.httpx_keep_alive_timeout From 66f51b6a880bb103414196d693f628203dc41951 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Fri, 8 Mar 2024 15:32:48 +0100 Subject: [PATCH 182/218] [#53369] replace authorization state with auth check query - fixed unit test setup - removed old usages of connection manager --- app/controllers/oauth_clients_controller.rb | 7 +- .../oauth_clients/connection_manager.rb | 51 +------ .../common/storages/peripherals/nextcloud.rb | 1 + .../configuration_interface.rb | 15 +- .../nextcloud_configuration.rb | 13 -- .../one_drive_configuration.rb | 9 -- .../common/storages/peripherals/one_drive.rb | 1 + .../storage_interaction/authentication.rb | 2 +- .../nextcloud/auth_check_query.rb | 71 ++++++++++ .../one_drive/auth_check_query.rb | 71 ++++++++++ .../api/v3/storages/storage_representer.rb | 20 +-- .../storages_representer_rendering_spec.rb | 10 +- .../api/v3/file_links/file_links_api_spec.rb | 11 +- .../api/v3/storages/storages_api_spec.rb | 25 ++-- .../oauth_clients/connection_manager_spec.rb | 86 ------------ .../one_drive_connection_manager_spec.rb | 132 ------------------ 16 files changed, 178 insertions(+), 347 deletions(-) create mode 100644 modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb create mode 100644 modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb diff --git a/app/controllers/oauth_clients_controller.rb b/app/controllers/oauth_clients_controller.rb index b0458a7d27f7..48389a335a43 100644 --- a/app/controllers/oauth_clients_controller.rb +++ b/app/controllers/oauth_clients_controller.rb @@ -70,6 +70,7 @@ def callback end end + # rubocop:disable Metrics/AbcSize def ensure_connection client_id = params.fetch(:oauth_client_id) storage_id = params.fetch(:storage_id) @@ -88,7 +89,9 @@ def ensure_connection else root_url end - if connection_manager.authorization_state_connected? + auth_state = ::Storages::Peripherals::StorageInteraction::Authentication + .authorization_state(storage: oauth_client.integration, user: User.current) + if auth_state == :connected redirect_to(destination_url) else nonce = SecureRandom.uuid @@ -97,6 +100,8 @@ def ensure_connection end end + # rubocop:enable Metrics/AbcSize + private def handle_absent_oauth_client diff --git a/app/services/oauth_clients/connection_manager.rb b/app/services/oauth_clients/connection_manager.rb index ac0d6e70462b..6875c40902c1 100644 --- a/app/services/oauth_clients/connection_manager.rb +++ b/app/services/oauth_clients/connection_manager.rb @@ -55,7 +55,7 @@ def get_access_token(state: nil) # Return the Nextcloud OAuth authorization URI that a user needs to open to grant access and eventually obtain # a token. - @redirect_url = get_authorization_uri(state:) + @redirect_url = @config.authorization_uri(state:) ServiceResult.failure(result: @redirect_url) end @@ -90,19 +90,6 @@ def refresh_token end end - # rubocop:enable Metrics/AbcSize - - # Returns the URI of the "authorize" endpoint of the OAuth2 Authorization Server. - # @param state (OAuth2 RFC) is a nonce referencing a cookie containing the calling page (URL + params) to which to - # return to at the end of the whole flow. - # @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud has only one global scope. - def get_authorization_uri(state: nil) - client = rack_oauth_client # Configure and start the rack-oauth2 client - client.authorization_uri(scope: @config.scope, state:) - end - - # rubocop:disable Metrics/AbcSize - # Called by callback_page with a cryptographic "code" that indicates # that the user has successfully authorized the OAuth2 Authorization Server. # We now are going to exchange this code to a token (bearer+refresh) @@ -140,42 +127,6 @@ def code_to_token(code) # rubocop:enable Metrics/AbcSize - # Called by StorageRepresenter to inquire about the status of the OAuth2 - # authentication server. - # Returns :connected/:authorization_failed or :error for a general error. - # We have decided to distinguish between only these 3 cases, because the - # front-end (and a normal user) probably wouldn't know how to deal with - # other options. - def authorization_state - oauth_client_token = get_existing_token - return :failed_authorization unless oauth_client_token - - state = @config.authorization_state_check(oauth_client_token.access_token) - case state - when :success - :connected - when :refresh_needed - service_result = refresh_token - if service_result.success? - :connected - elsif service_result.errors.data.payload[:error] == 'invalid_request' - :failed_authorization - else - :error - end - else - state - end - rescue StandardError - :error - end - - %i[connected failed_authorization error].each do |authorization_result| - define_method(:"authorization_state_#{authorization_result}?") do - authorization_state == authorization_result - end - end - # @returns ServiceResult with result to be :error or any type of object with data def request_with_token_refresh(oauth_client_token) # `yield` needs to returns a ServiceResult: diff --git a/modules/storages/app/common/storages/peripherals/nextcloud.rb b/modules/storages/app/common/storages/peripherals/nextcloud.rb index 0d5a40cc18b1..d59f4d003d3c 100644 --- a/modules/storages/app/common/storages/peripherals/nextcloud.rb +++ b/modules/storages/app/common/storages/peripherals/nextcloud.rb @@ -32,6 +32,7 @@ module Storages module Peripherals Nextcloud = Dry::Container::Namespace.new('nextcloud') do namespace('queries') do + register(:auth_check, StorageInteraction::Nextcloud::AuthCheckQuery) register(:download_link, StorageInteraction::Nextcloud::DownloadLinkQuery) register(:file_ids, StorageInteraction::Nextcloud::FileIdsQuery) register(:file_info, StorageInteraction::Nextcloud::FileInfoQuery) diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb index a5442092376c..a4e457ff49e6 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb @@ -32,25 +32,14 @@ module Storages module Peripherals module OAuthConfigurations class ConfigurationInterface - def authorization_state_check(_) = raise ::Storages::Errors::SubclassResponsibility - def scope = raise ::Storages::Errors::SubclassResponsibility def basic_rack_oauth_client = raise ::Storages::Errors::SubclassResponsibility def to_httpx_oauth_config = raise ::Storages::Errors::SubclassResponsibility - private - - def authorization_check_wrapper - case yield - in { status: 200..299 } - :success - in { status: 401 | 403 } - :refresh_needed - else - :error - end + def authorization_uri(state:) + basic_rack_oauth_client.authorization_uri(scope:, state:) end end end diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb index 33570cad6b5d..eb6c47c6db8d 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb @@ -44,19 +44,6 @@ def initialize(storage) # rubocop:enable Lint/MissingSuper - def authorization_state_check(token) - authorization_check_wrapper do - OpenProject.httpx.get( - Util.join_uri_path(@uri, "/ocs/v1.php/cloud/user"), - headers: { - "Authorization" => "Bearer #{token}", - "OCS-APIRequest" => "true", - "Accept" => "application/json" - } - ) - end - end - def extract_origin_user_id(rack_access_token) rack_access_token.raw_attributes[:user_id] end diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb index c66ea8c86a2e..9b9568d601f7 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb @@ -46,15 +46,6 @@ def initialize(storage) # rubocop:enable Lint/MissingSuper - def authorization_state_check(access_token) - authorization_check_wrapper do - OpenProject.httpx.get( - Util.join_uri_path(@uri, "/v1.0/me"), - headers: { "Authorization" => "Bearer #{access_token}", "Accept" => "application/json" } - ) - end - end - def extract_origin_user_id(rack_access_token) OpenProject.httpx.get( Util.join_uri_path(@uri, "/v1.0/me"), diff --git a/modules/storages/app/common/storages/peripherals/one_drive.rb b/modules/storages/app/common/storages/peripherals/one_drive.rb index 399df0b46c39..5ec1cc2975a6 100644 --- a/modules/storages/app/common/storages/peripherals/one_drive.rb +++ b/modules/storages/app/common/storages/peripherals/one_drive.rb @@ -32,6 +32,7 @@ module Storages module Peripherals OneDrive = Dry::Container::Namespace.new('one_drive') do namespace('queries') do + register(:auth_check, StorageInteraction::OneDrive::AuthCheckQuery) register(:download_link, StorageInteraction::OneDrive::DownloadLinkQuery) register(:files, StorageInteraction::OneDrive::FilesQuery) register(:file_info, StorageInteraction::OneDrive::FileInfoQuery) diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb index 908ea6a6eec0..f4807cb61c22 100644 --- a/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb @@ -59,7 +59,7 @@ def self.authorization_state(storage:, user:) .resolve("#{storage.short_provider_type}.queries.auth_check") .call(storage:, auth_strategy:) .match( - on_success: ->(result) { result }, + on_success: ->(*) { :connected }, on_failure: ->(error) { error.code == :unauthorized ? :failed_authorization : :error } ) end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb new file mode 100644 index 000000000000..16a58ee046de --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Storages + module Peripherals + module StorageInteraction + module Nextcloud + class AuthCheckQuery + Auth = ::Storages::Peripherals::StorageInteraction::Authentication + + using ServiceResultRefinements + + def self.call(storage:, auth_strategy:) + new(storage).call(auth_strategy:) + end + + def initialize(storage) + @storage = storage + end + + def call(auth_strategy:) + Auth[auth_strategy].call(storage: @storage, http_options: Util.ocs_api_request) do |http| + handle_response http.get(Util.join_uri_path(@storage.uri, '/ocs/v1.php/cloud/user')) + end + end + + private + + def handle_response(response) + case response + in { status: 200..299 } + ServiceResult.success + in { status: 401 } + ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized)) + else + data = ::Storages::StorageErrorData.new(source: self, payload: response) + ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:)) + end + end + end + end + end + end +end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb new file mode 100644 index 000000000000..812525fb7f8a --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Storages + module Peripherals + module StorageInteraction + module OneDrive + class AuthCheckQuery + Auth = ::Storages::Peripherals::StorageInteraction::Authentication + + using ServiceResultRefinements + + def self.call(storage:, auth_strategy:) + new(storage).call(auth_strategy:) + end + + def initialize(storage) + @storage = storage + end + + def call(auth_strategy:) + Auth[auth_strategy].call(storage: @storage) do |http| + handle_response http.get(Util.join_uri_path(@storage.uri, '/v1.0/me')) + end + end + + private + + def handle_response(response) + case response + in { status: 200..299 } + ServiceResult.success + in { status: 401 } + ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized)) + else + data = ::Storages::StorageErrorData.new(source: self, payload: response) + ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:)) + end + end + end + end + end + end +end diff --git a/modules/storages/lib/api/v3/storages/storage_representer.rb b/modules/storages/lib/api/v3/storages/storage_representer.rb index 405a5f163238..722a39135940 100644 --- a/modules/storages/lib/api/v3/storages/storage_representer.rb +++ b/modules/storages/lib/api/v3/storages/storage_representer.rb @@ -74,16 +74,6 @@ def link_without_resource(name, getter:, setter:) extend ClassMethods - def initialize(model, current_user:, embed_links: nil) - if model.oauth_configuration.present? - # Do not instantiate a connection manager, if representer is used for parsing - @connection_manager = - ::OAuthClients::ConnectionManager.new(user: current_user, configuration: model.oauth_configuration) - end - - super - end - property :id property :name @@ -158,7 +148,8 @@ def initialize(model, current_user:, embed_links: nil) end link :authorizationState do - urn = case authorization_state + auth_state = authorization_state + urn = case auth_state when :connected URN_CONNECTION_CONNECTED when :failed_authorization @@ -166,7 +157,7 @@ def initialize(model, current_user:, embed_links: nil) else URN_CONNECTION_ERROR end - title = I18n.t(:"oauth_client.urn_connection_status.#{authorization_state}") + title = I18n.t(:"oauth_client.urn_connection_status.#{auth_state}") { href: urn, title: } end @@ -174,7 +165,7 @@ def initialize(model, current_user:, embed_links: nil) link :authorize do next unless authorization_state == :failed_authorization - { href: @connection_manager.get_authorization_uri, title: 'Authorize' } + { href: represented.oauth_configuration.authorization_uri(state: nil), title: 'Authorize' } end link :projectStorages do @@ -231,7 +222,8 @@ def storage_projects_ids(storage) end def authorization_state - @authorization_state ||= @connection_manager.authorization_state + ::Storages::Peripherals::StorageInteraction::Authentication.authorization_state(storage: represented, + user: current_user) end end end diff --git a/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb b/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb index 65ca341bbce7..2dbda82112f7 100644 --- a/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb +++ b/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb @@ -36,16 +36,16 @@ let(:oauth_client_credentials) { build_stubbed(:oauth_client) } let(:storage) { build_stubbed(:nextcloud_storage, oauth_application:, oauth_client: oauth_client_credentials) } let(:user) { build_stubbed(:user) } + let(:auth_check_result) { ServiceResult.success } let(:representer) { described_class.new(storage, current_user: user, embed_links: true) } - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } subject(:generated) { representer.to_json } before do - allow(OAuthClients::ConnectionManager) - .to receive(:new).and_return(connection_manager) - allow(connection_manager) - .to receive_messages(authorization_state: :connected, get_authorization_uri: 'https://example.com/authorize') + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { auth_check_result } + ) end describe '_links' do diff --git a/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb b/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb index c1d5cf5b3ff4..0a1e11a04b56 100644 --- a/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb +++ b/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb @@ -65,7 +65,7 @@ def disable_module(project, modul) shared_let(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user: current_user) } shared_let(:project_storage) { create(:project_storage, project:, storage:) } - let!(:another_project_storage) { nil } # create(:project_storage, project:, storage: another_storage) + let!(:another_project_storage) { nil } let(:file_link) do create(:file_link, creator: current_user, container: work_package, storage:) @@ -79,20 +79,11 @@ def disable_module(project, modul) create(:file_link, creator: current_user, container: work_package, storage: another_storage) end - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } let(:sync_service) { instance_double(Storages::FileLinkSyncService) } subject(:response) { last_response } before do - # Mock ConnectionManager to behave as if connected - allow(OAuthClients::ConnectionManager) - .to receive(:new).and_return(connection_manager) - allow(connection_manager) - .to receive_messages(get_access_token: ServiceResult.success(result: oauth_client_token), authorization_state: :connected, - get_authorization_uri: 'https://example.com/authorize') - - # Mock FileLinkSyncService as if Nextcloud would respond positively allow(Storages::FileLinkSyncService) .to receive(:new).and_return(sync_service) allow(sync_service).to receive(:call) do |file_links| diff --git a/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb b/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb index f1db282004e3..31011698613e 100644 --- a/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb +++ b/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb @@ -43,20 +43,23 @@ shared_let(:user_without_project) { create(:user) } shared_let(:admin) { create(:admin) } shared_let(:oauth_application) { create(:oauth_application) } - shared_let(:storage) { create(:nextcloud_storage, creator: user_with_permissions, oauth_application:) } + shared_let(:oauth_client) { create(:oauth_client) } + shared_let(:storage) { create(:nextcloud_storage, creator: user_with_permissions, oauth_application:, oauth_client:) } shared_let(:project_storage) { create(:project_storage, project:, storage:) } - let(:authorize_url) { 'https://example.com/authorize' } - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } let(:current_user) { user_with_permissions } + let(:auth_check_result) { ServiceResult.success } subject(:last_response) do get path end before do - allow(connection_manager).to receive_messages(get_authorization_uri: authorize_url, authorization_state: :connected) - allow(OAuthClients::ConnectionManager).to receive(:new).and_return(connection_manager) + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { auth_check_result } + ) + login_as current_user end @@ -233,17 +236,13 @@ shared_examples 'a storage authorization result' do |expected:, has_authorize_link:| subject { last_response.body } - before do - allow(connection_manager).to receive(:authorization_state).and_return(authorization_state) - end - it "returns #{expected}" do expect(subject).to be_json_eql(expected.to_json).at_path('_links/authorizationState/href') end it "has #{has_authorize_link ? '' : 'no'} authorize link" do if has_authorize_link - expect(subject).to be_json_eql(authorize_url.to_json).at_path('_links/authorize/href') + expect(subject).to have_json_path('_links/authorize/href') else expect(subject).not_to have_json_path('_links/authorize/href') end @@ -251,7 +250,7 @@ end context 'when authorization succeeds and storage is connected' do - let(:authorization_state) { :connected } + let(:auth_check_result) { ServiceResult.success } include_examples 'a storage authorization result', expected: API::V3::Storages::URN_CONNECTION_CONNECTED, @@ -259,7 +258,7 @@ end context 'when authorization fails' do - let(:authorization_state) { :failed_authorization } + let(:auth_check_result) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } include_examples 'a storage authorization result', expected: API::V3::Storages::URN_CONNECTION_AUTH_FAILED, @@ -267,7 +266,7 @@ end context 'when authorization fails with an error' do - let(:authorization_state) { :error } + let(:auth_check_result) { ServiceResult.failure(errors: Storages::StorageError.new(code: :error)) } include_examples 'a storage authorization result', expected: API::V3::Storages::URN_CONNECTION_ERROR, diff --git a/spec/services/oauth_clients/connection_manager_spec.rb b/spec/services/oauth_clients/connection_manager_spec.rb index b89a424a7196..fb464a3dfa5a 100644 --- a/spec/services/oauth_clients/connection_manager_spec.rb +++ b/spec/services/oauth_clients/connection_manager_spec.rb @@ -493,92 +493,6 @@ end end - describe '#authorization_state' do - subject { instance.authorization_state } - - context 'without access token present' do - it 'returns :failed_authorization' do - expect(subject).to eq :failed_authorization - expect(instance).to be_authorization_state_failed_authorization - end - end - - context 'with access token present', :webmock do - before do - oauth_client_token - end - - context 'with access token valid' do - context 'without other errors or exceptions' do - before do - allow(configuration).to receive(:authorization_state_check).and_return(:success) - end - - it 'returns :connected' do - expect(subject).to eq :connected - expect(instance).to be_authorization_state_connected - end - end - - context 'with some other error or exception' do - before do - allow(configuration).to receive(:authorization_state_check).and_return(:error) - end - - it 'returns :error' do - expect(subject).to eq :error - expect(instance).to be_authorization_state_error - end - end - end - - context 'with outdated access token' do - let(:new_oauth_client_token) { create(:oauth_client_token) } - let(:refresh_service_result) { ServiceResult.success } - - before do - allow(configuration).to receive(:authorization_state_check).and_return(:refresh_needed) - allow(instance).to receive(:refresh_token).and_return(refresh_service_result) - end - - context 'with valid refresh token' do - it 'refreshes the access token and returns :connected' do - expect(subject).to eq :connected - expect(instance).to have_received(:refresh_token) - end - end - - context 'with invalid refresh token' do - let(:refresh_service_result) do - data = Storages::StorageErrorData.new(source: nil, payload: { error: 'invalid_request' }) - ServiceResult.failure(result: :bad_request, - errors: Storages::StorageError.new(code: :bad_request, data:)) - end - - it 'refreshes the access token and returns :failed_authorization' do - expect(subject).to eq :failed_authorization - expect(instance).to have_received(:refresh_token) - end - end - - context 'with some other error while refreshing access token' do - let(:refresh_service_result) { ServiceResult.failure } - - it 'returns :error' do - expect(subject).to eq :error - expect(instance).to have_received(:refresh_token) - end - end - end - end - - context 'with both invalid access token and refresh token', :webmock do - it 'returns :failed_authorization' do - expect(subject).to eq :failed_authorization - end - end - end - describe '#request_with_token_refresh' do let(:yield_service_result) { ServiceResult.success } let(:refresh_service_result) { ServiceResult.success } diff --git a/spec/services/oauth_clients/one_drive_connection_manager_spec.rb b/spec/services/oauth_clients/one_drive_connection_manager_spec.rb index 9f530e21b350..76ddc524188f 100644 --- a/spec/services/oauth_clients/one_drive_connection_manager_spec.rb +++ b/spec/services/oauth_clients/one_drive_connection_manager_spec.rb @@ -133,136 +133,4 @@ end end end - - describe '#authorization_state' do - subject(:authorization_state) { connection_manager.authorization_state } - - context 'without access token present' do - it 'returns :failed_authorization' do - expect(authorization_state).to eq :failed_authorization - end - end - - context 'with access token present', :webmock do - before { token } - - context 'with access token valid' do - context 'without other errors or exceptions' do - before { stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 200) } - - it 'returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with some other error or exception' do - before { stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_timeout } - - it 'returns :error' do - expect(authorization_state).to eq :error - end - end - end - - context 'with outdated access token' do - shared_examples 'refresh' do |_code| - before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 401) - end - - context 'with valid refresh token' do - it 'refreshes the access token and returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with invalid refresh token' do - it 'refreshes the access token and returns :failed_authorization' do - allow(token).to receive(:updated_at).and_return(3.months.ago) - expect(authorization_state).to eq :failed_authorization - end - end - - context 'with some other error while refreshing access token' do - it 'returns :error' do - expect(authorization_state).to eq :error - end - end - end - - context 'when Unauthorized is returned' do - before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 401) - end - - context 'with valid refresh token' do - before do - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 200, - body: { access_token: 'ThereIsNoPeaceOnlyPassion' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - end - - it 'refreshes the access token and returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with invalid refresh token' do - before do - token.update!(updated_at: 3.months.ago) - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 400, - body: { error: 'invalid_request', error_message: 'nope. not happening' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - end - - it 'refreshes the access token and returns :failed_authorization' do - expect(authorization_state).to eq :failed_authorization - end - end - - context 'with some other error while refreshing access token' do - it 'returns :error' do - token.update!(updated_at: 3.months.ago) - - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 401) - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 400, - body: { error: 'you_error', error_message: 'it is not me, it is you' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - - expect(authorization_state).to eq :error - end - end - end - - context 'when Forbidden is returned' do - before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 403) - end - - context 'with valid refresh token' do - it 'refreshes the access token and returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with invalid refresh token' do - before do - token.update!(updated_at: 3.months.ago) - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 400, - body: { error: 'invalid_request', error_message: 'nope. not happening' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - end - - it 'refreshes the access token and returns :failed_authorization' do - expect(authorization_state).to eq :failed_authorization - end - end - end - end - end - end end From a77b1406b064c16ad8e5227c833ae670c547d2ae Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Wed, 20 Mar 2024 16:21:46 +0100 Subject: [PATCH 183/218] [#53369] update usage of authorization state --- .../configuration_interface.rb | 2 +- .../nextcloud/auth_check_query.rb | 4 +- .../one_drive/auth_check_query.rb | 4 +- .../admin/project_storages_controller.rb | 15 +- .../app/helpers/storage_login_helper.rb | 5 +- .../api/v3/storages/storage_representer.rb | 16 +- .../ensure_connection_flow_spec.rb | 30 ++- .../oauth_clients/connection_manager_spec.rb | 234 +++++++----------- .../one_drive_connection_manager_spec.rb | 51 ++-- 9 files changed, 154 insertions(+), 207 deletions(-) diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb index a4e457ff49e6..78d22ce075a8 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb @@ -38,7 +38,7 @@ def basic_rack_oauth_client = raise ::Storages::Errors::SubclassResponsibility def to_httpx_oauth_config = raise ::Storages::Errors::SubclassResponsibility - def authorization_uri(state:) + def authorization_uri(state: nil) basic_rack_oauth_client.authorization_uri(scope:, state:) end end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb index 16a58ee046de..a2d33060540a 100644 --- a/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb @@ -47,7 +47,7 @@ def initialize(storage) def call(auth_strategy:) Auth[auth_strategy].call(storage: @storage, http_options: Util.ocs_api_request) do |http| - handle_response http.get(Util.join_uri_path(@storage.uri, '/ocs/v1.php/cloud/user')) + handle_response http.get(Util.join_uri_path(@storage.uri, "/ocs/v1.php/cloud/user")) end end @@ -60,7 +60,7 @@ def handle_response(response) in { status: 401 } ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized)) else - data = ::Storages::StorageErrorData.new(source: self, payload: response) + data = ::Storages::StorageErrorData.new(source: self.class, payload: response) ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:)) end end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb index 812525fb7f8a..dec0178eb7bc 100644 --- a/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb @@ -47,7 +47,7 @@ def initialize(storage) def call(auth_strategy:) Auth[auth_strategy].call(storage: @storage) do |http| - handle_response http.get(Util.join_uri_path(@storage.uri, '/v1.0/me')) + handle_response http.get(Util.join_uri_path(@storage.uri, "/v1.0/me")) end end @@ -60,7 +60,7 @@ def handle_response(response) in { status: 401 } ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized)) else - data = ::Storages::StorageErrorData.new(source: self, payload: response) + data = ::Storages::StorageErrorData.new(source: self.class, payload: response) ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:)) end end diff --git a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb index 60dbbfe08419..890f7307b04a 100644 --- a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb @@ -95,12 +95,11 @@ def create def oauth_access_grant # rubocop:disable Metrics/AbcSize @project_storage = @object - connection_manager = OAuthClients::ConnectionManager.new( - user: current_user, - configuration: @project_storage.storage.oauth_configuration - ) + storage = @project_storage.storage + auth_state = ::Storages::Peripherals::StorageInteraction::Authentication + .authorization_state(storage:, user: current_user) - if connection_manager.authorization_state_connected? + if auth_state == :connected redirect_to(project_settings_project_storages_path) else nonce = SecureRandom.uuid @@ -110,11 +109,11 @@ def oauth_access_grant # rubocop:disable Metrics/AbcSize expires: 1.hour } session[:oauth_callback_flash_modal] = oauth_access_grant_nudge_modal(authorized: true) - redirect_to(connection_manager.get_authorization_uri(state: nonce)) + redirect_to(storage.oauth_configuration.authorization_uri(state: nonce)) end end - # Edit page is very similar to new page, except that we don't need to set + # Edit page is very similar to new page, except that we don"t need to set # default attribute values because the object already exists # Called by: Global app/config/routes.rb to serve Web page def edit @@ -150,7 +149,7 @@ def update end # Purpose: Destroy a ProjectStorage object - # Called by: By pressing a "Delete" icon in the Project's settings ProjectStorages page + # Called by: By pressing a "Delete" icon in the Project"s settings ProjectStorages page # It redirects back to the list of ProjectStorages in the project def destroy # The complex logic for deleting associated objects was moved into a service: diff --git a/modules/storages/app/helpers/storage_login_helper.rb b/modules/storages/app/helpers/storage_login_helper.rb index 7d0c5a0a785c..db5f1e27b7f0 100644 --- a/modules/storages/app/helpers/storage_login_helper.rb +++ b/modules/storages/app/helpers/storage_login_helper.rb @@ -32,14 +32,11 @@ module StorageLoginHelper def storage_login_input(storage) return {} if storage&.oauth_client.blank? - connection_manager = ::OAuthClients::ConnectionManager - .new(user: current_user, configuration: storage.oauth_configuration) - { storageId: storage.id, storageType: API::V3::Storages::STORAGE_TYPE_URN_MAP[storage.provider_type], authorizationLink: { - href: connection_manager.get_authorization_uri + href: storage.oauth_configuration.authorization_uri } } end diff --git a/modules/storages/lib/api/v3/storages/storage_representer.rb b/modules/storages/lib/api/v3/storages/storage_representer.rb index 722a39135940..24c95803d08b 100644 --- a/modules/storages/lib/api/v3/storages/storage_representer.rb +++ b/modules/storages/lib/api/v3/storages/storage_representer.rb @@ -110,10 +110,10 @@ def link_without_resource(name, getter:, setter:) getter: ->(*) { type = STORAGE_TYPE_URN_MAP[represented.provider_type] || represented.provider_type - { href: type, title: 'Nextcloud' } + { href: type, title: "Nextcloud" } }, setter: ->(fragment:, **) { - href = fragment['href'] + href = fragment["href"] break if href.blank? represented.provider_type = STORAGE_TYPE_MAP[href] || href @@ -122,9 +122,9 @@ def link_without_resource(name, getter:, setter:) link_without_resource :origin, getter: ->(*) { { href: represented.host } }, setter: ->(fragment:, **) { - break if fragment['href'].blank? + break if fragment["href"].blank? - represented.host = fragment['href'].gsub(/\/+$/, '') + represented.host = fragment["href"].gsub(/\/+$/, "") } links :prepareUpload do @@ -135,8 +135,8 @@ def link_without_resource(name, getter:, setter:) title: "Upload file", payload: { projectId: project_id, - fileName: '{fileName}', - parent: '{parent}' + fileName: "{fileName}", + parent: "{parent}" }, templated: true } @@ -165,7 +165,7 @@ def link_without_resource(name, getter:, setter:) link :authorize do next unless authorization_state == :failed_authorization - { href: represented.oauth_configuration.authorization_uri(state: nil), title: 'Authorize' } + { href: represented.oauth_configuration.authorization_uri, title: "Authorize" } end link :projectStorages do @@ -204,7 +204,7 @@ def link_without_resource(name, getter:, setter:) } def _type - 'Storage' + "Storage" end private diff --git a/spec/requests/oauth_clients/ensure_connection_flow_spec.rb b/spec/requests/oauth_clients/ensure_connection_flow_spec.rb index fcc3b3fc854e..eced9e187b6e 100644 --- a/spec/requests/oauth_clients/ensure_connection_flow_spec.rb +++ b/spec/requests/oauth_clients/ensure_connection_flow_spec.rb @@ -27,13 +27,20 @@ #++ require "spec_helper" -require_module_spec_helper +require_relative "../../../modules/storages/spec/spec_helper" RSpec.describe "/oauth_clients/:oauth_client_id/ensure_connection endpoint", :webmock do shared_let(:user) { create(:user) } shared_let(:storage) { create(:nextcloud_storage, :with_oauth_client) } shared_let(:oauth_client) { storage.oauth_client } + before do + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { ServiceResult.success } + ) + end + describe "#ensure_connection" do context "when user is not logged in" do it "requires login" do @@ -52,10 +59,15 @@ end context "when storage_id parameter is present" do - context 'when user is not "connected"' do + context "when user is not 'connected'" do let(:nonce) { "57a17c3f-b2ed-446e-9dd8-651ba3aec37d" } before do + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } + ) + allow(SecureRandom).to receive(:uuid).and_call_original.ordered allow(SecureRandom).to receive(:uuid).and_return(nonce).ordered end @@ -72,7 +84,9 @@ "%2Foauth_clients%2F#{oauth_client.client_id}%2F" \ "callback&response_type=code&state=#{nonce}" ) - expect(last_response.cookies["oauth_state_#{nonce}"]).to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) + expect(last_response.cookies["oauth_state_#{nonce}"]) + .to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com" \ + "%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) end end @@ -91,7 +105,9 @@ "%2Foauth_clients%2F#{oauth_client.client_id}%2F" \ "callback&response_type=code&state=#{nonce}" ) - expect(last_response.cookies["oauth_state_#{nonce}"]).to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com%2F123%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) + expect(last_response.cookies["oauth_state_#{nonce}"]) + .to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com" \ + "%2F123%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) end end @@ -109,13 +125,15 @@ "%2Foauth_clients%2F#{oauth_client.client_id}%2F" \ "callback&response_type=code&state=#{nonce}" ) - expect(last_response.cookies["oauth_state_#{nonce}"]).to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) + expect(last_response.cookies["oauth_state_#{nonce}"]) + .to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com" \ + "%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) end end end end - context 'when user is "connected"' do + context "when user is 'connected'" do let!(:oauth_client_token) do create(:oauth_client_token, oauth_client:, user:) end diff --git a/spec/services/oauth_clients/connection_manager_spec.rb b/spec/services/oauth_clients/connection_manager_spec.rb index fb464a3dfa5a..a707b7ee3ef7 100644 --- a/spec/services/oauth_clients/connection_manager_spec.rb +++ b/spec/services/oauth_clients/connection_manager_spec.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" RSpec.describe OAuthClients::ConnectionManager, :webmock, type: :model do using Storages::Peripherals::ServiceResultRefinements @@ -45,78 +45,24 @@ let(:instance) { described_class.new(user:, configuration:) } - # The get_authorization_uri method returns the OAuth2 authorization URI as a string. That URI is the starting point for - # a user to grant OpenProject access to Nextcloud. - describe '#get_authorization_uri' do - let(:scope) { nil } - let(:state) { nil } - - subject { instance.get_authorization_uri(state:) } - - context 'with empty state and scope' do - shared_examples_for 'returns the authorization URI relative to the host' do - it 'returns the authorization URI' do - expect(subject).to be_a String - expect(subject).to include oauth_client.integration.host - expect(subject).not_to include "scope" - expect(subject).not_to include "state" - end - end - - context 'when Nextcloud is installed in the server root' do - it_behaves_like 'returns the authorization URI relative to the host' - end - - context 'when Nextcloud is installed in a sub-directory' do - let(:host) { "https://example.org/nextcloud" } - - it_behaves_like 'returns the authorization URI relative to the host' - end - end - - context 'with state but empty scope' do - let(:state) { "https://example.com/page" } - - it 'returns the redirect URL' do - expect(subject).to be_a String - expect(subject).to include oauth_client.integration.host - expect(subject).not_to include "scope" - expect(subject).to include "&state=https" - end - end - - context 'with multiple scopes but empty state' do - let(:scope) { %i(email profile) } - - it 'returns the redirect URL' do - allow(configuration).to receive(:scope).and_return(scope) - - expect(subject).to be_a String - expect(subject).to include oauth_client.integration.host - expect(subject).not_to include "state" - expect(subject).to include "&scope=email%20profile" - end - end - end - # The first step in the OAuth2 flow is to produce a URL for the # user to authenticate and authorize access at the OAuth2 provider # (Nextcloud). - describe '#get_access_token' do + describe "#get_access_token" do subject { instance.get_access_token } - context 'with no OAuthClientToken present' do - it 'returns a redirection URL' do + context "with no OAuthClientToken present" do + it "returns a redirection URL" do expect(subject.success).to be_falsy expect(subject.result).to be_a String # Details of string are tested above in section #get_authorization_uri end end - context 'with no OAuthClientToken present and state parameters' do + context "with no OAuthClientToken present and state parameters" do subject { instance.get_access_token(state: "some_state") } - it 'returns the redirect URL' do + it "returns the redirect URL" do allow(configuration).to receive(:scope).and_return(%w[email]) expect(subject.success).to be_falsy @@ -127,12 +73,12 @@ end end - context 'with an OAuthClientToken present' do + context "with an OAuthClientToken present" do before do oauth_client_token end - it 'returns the OAuthClientToken' do + it "returns the OAuthClientToken" do expect(subject).to be_truthy expect(subject.result).to be_a OAuthClientToken # The one and only... expect(subject.result).to eql oauth_client_token @@ -147,12 +93,12 @@ # The callback endpoint calls `code_to_token(code)` with the code # received and exchanges the code for a bearer+refresh token # using a HTTP request. - describe '#code_to_token', :webmock do + describe "#code_to_token", :webmock do let(:code) { "7kRGJ...jG3KZ" } subject { instance.code_to_token(code) } - context 'with happy path' do + context "with happy path" do before do # Simulate a successful authorization returning the tokens response_body = { @@ -162,11 +108,11 @@ refresh_token: "UwFp...1FROJ", user_id: "admin" }.to_json - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns a valid ClientToken object and issues an appropriate event' do + it "returns a valid ClientToken object and issues an appropriate event" do expect(OpenProject::Notifications) .to(receive(:send)) .with(OpenProject::Events::OAUTH_CLIENT_TOKEN_CREATED, integration_type: "Storages::Storage") @@ -174,156 +120,156 @@ expect(subject.result).to be_a OAuthClientToken end - it 'fills in the origin_user_id' do + it "fills in the origin_user_id" do expect { subject }.to change(OAuthClientToken, :count).by(1) - last_token = OAuthClientToken.where(access_token: 'yjTDZ...RYvRH').last + last_token = OAuthClientToken.where(access_token: "yjTDZ...RYvRH").last - expect(last_token.origin_user_id).to eq('admin') + expect(last_token.origin_user_id).to eq("admin") end end - context 'with known error' do + context "with known error" do before do - stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:post, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 400, body: { error: error_message }.to_json, headers: { "content-type" => "application/json; charset=utf-8" }) end - shared_examples 'OAuth2 error response' do - it 'returns a specific error message' do + shared_examples "OAuth2 error response" do + it "returns a specific error message" do expect(subject.success).to be_falsy expect(subject.result).to eq(:bad_request) expect(subject.error_payload[:error]).to eq(error_message) end end - context 'when "invalid_request"' do - let(:error_message) { 'invalid_request' } + context "when 'invalid_request'" do + let(:error_message) { "invalid_request" } - it_behaves_like 'OAuth2 error response' + it_behaves_like "OAuth2 error response" end - context 'when "invalid_grant"' do - let(:error_message) { 'invalid_grant' } + context "when 'invalid_grant'" do + let(:error_message) { "invalid_grant" } - it_behaves_like 'OAuth2 error response' + it_behaves_like "OAuth2 error response" end end - context 'with unknown reply' do + context "with unknown reply" do before do - stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:post, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 400, body: { error: "invalid_requesttt" }.to_json, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns an error wrapping the unknown response' do + it "returns an error wrapping the unknown response" do expect(subject.success).to be_falsy expect(subject.result).to eq(:bad_request) - expect(subject.error_payload[:error]).to eq('invalid_requesttt') + expect(subject.error_payload[:error]).to eq("invalid_requesttt") expect(subject.error_source).to be_a(described_class) - expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_error') + expect(subject.errors.log_message).to include I18n.t("oauth_client.errors.oauth_returned_error") end end - context 'with reply including JSON syntax error' do + context "with reply including JSON syntax error" do before do - stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:post, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return( status: 400, - headers: { 'Content-Type' => 'application/json; charset=utf-8' }, + headers: { "Content-Type" => "application/json; charset=utf-8" }, body: "some: very, invalid> "application/json; charset=utf-8" }) end - it 'returns a valid ClientToken object', :webmock do + it "returns a valid ClientToken object", :webmock do expect(subject.success).to be_truthy expect(subject.result).to be_a OAuthClientToken expect(subject.result.access_token).to eq("xyjTDZ...RYvRH") @@ -346,7 +292,7 @@ end end - context 'with invalid access_token data' do + context "with invalid access_token data" do before do # Simulate a token too long response_body = { @@ -356,11 +302,11 @@ refresh_token: "xUwFp...1FROJ", user_id: "admin" }.to_json - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns dependent error from model validation', :webmock do + it "returns dependent error from model validation", :webmock do expect(subject.success).to be_falsy expect(subject.result).to eq(:error) expect(subject.error_payload.class).to be(AttrRequired::AttrMissing) @@ -368,38 +314,38 @@ end end - context 'with server error from OAuth2 provider' do + context "with server error from OAuth2 provider" do before do - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 400, body: { error: "invalid_request" }.to_json, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns a server error', :webmock do + it "returns a server error", :webmock do expect(subject.success).to be_falsy expect(subject.result).to eq(:bad_request) - expect(subject.error_payload[:error]).to eq('invalid_request') + expect(subject.error_payload[:error]).to eq("invalid_request") end end - context 'with successful response but invalid data' do + context "with successful response but invalid data" do before do # Simulate timeout - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_timeout end - it 'returns an error wrapping a timeout', :webmock do + it "returns an error wrapping a timeout", :webmock do expect(subject.success).to be_falsy expect(subject.result).to eq(:internal_server_error) expect(subject.error_payload.class).to be(Faraday::ConnectionFailed) expect(subject.error_source).to be_a(described_class) - expect(subject.errors.log_message).to include('Faraday::ConnectionFailed: execution expired') + expect(subject.errors.log_message).to include("Faraday::ConnectionFailed: execution expired") end end - context 'with parallel requests for refresh', :aggregate_failures do + context "with parallel requests for refresh", :aggregate_failures do after do Storages::Storage.destroy_all User.destroy_all @@ -407,7 +353,7 @@ OAuthClient.destroy_all end - it 'requests token only once and other thread uses new token' do + it "requests token only once and other thread uses new token" do response_body1 = { access_token: "xyjTDZ...RYvRH", token_type: "Bearer", @@ -417,7 +363,7 @@ } response_body2 = response_body1.dup response_body2[:access_token] = "differ...RYvRH" - request_url = File.join(host, '/index.php/apps/oauth2/api/v1/token') + request_url = File.join(host, "/index.php/apps/oauth2/api/v1/token") stub_request(:any, request_url).to_return( { status: 200, body: response_body1.to_json, headers: { "content-type" => "application/json; charset=utf-8" } }, { status: 200, body: response_body2.to_json, headers: { "content-type" => "application/json; charset=utf-8" } } @@ -443,7 +389,7 @@ expect(WebMock).to have_requested(:any, request_url).once end - it 'requests token refresh twice if enough time passes between requests' do + it "requests token refresh twice if enough time passes between requests" do stub_const("OAuthClients::ConnectionManager::TOKEN_IS_FRESH_DURATION", 2.seconds) response_body1 = { access_token: "xyjTDZ...RYvRH", @@ -454,7 +400,7 @@ } response_body2 = response_body1.dup response_body2[:access_token] = "differ...RYvRH" - request_url = File.join(host, '/index.php/apps/oauth2/api/v1/token') + request_url = File.join(host, "/index.php/apps/oauth2/api/v1/token") headers = { "content-type" => "application/json; charset=utf-8" } stub_request(:any, request_url) .to_return(status: 200, body: response_body1.to_json, headers:).then @@ -483,8 +429,8 @@ end end - context 'when token is fresh' do - it 'does not send refresh request and respond with existing token', :webmock do + context "when token is fresh" do + it "does not send refresh request and respond with existing token", :webmock do expect(subject.success).to be_truthy expect(subject.result).to eq(oauth_client_token) expect { subject }.not_to change(oauth_client_token, :access_token) @@ -493,7 +439,7 @@ end end - describe '#request_with_token_refresh' do + describe "#request_with_token_refresh" do let(:yield_service_result) { ServiceResult.success } let(:refresh_service_result) { ServiceResult.success } @@ -506,18 +452,18 @@ allow(oauth_client_token).to receive(:reload) end - context 'with yield returning :success' do - it 'returns a ServiceResult with success, without refreshing the token' do + context "with yield returning :success" do + it "returns a ServiceResult with success, without refreshing the token" do expect(subject.success).to be_truthy expect(instance).not_to have_received(:refresh_token) expect(oauth_client_token).not_to have_received(:reload) end end - context 'with yield returning :error' do + context "with yield returning :error" do let(:yield_service_result) { ServiceResult.failure(result: :error) } - it 'returns a ServiceResult with success, without refreshing the token' do + it "returns a ServiceResult with success, without refreshing the token" do expect(subject.success).to be_falsy expect(subject.result).to be :error expect(instance).not_to have_received(:refresh_token) @@ -525,10 +471,10 @@ end end - context 'with yield returning :unauthorized and the refresh returning a with a success' do + context "with yield returning :unauthorized and the refresh returning a with a success" do let(:yield_service_result) { ServiceResult.failure(result: :unauthorized) } - it 'returns a ServiceResult with success, without refresh' do + it "returns a ServiceResult with success, without refresh" do expect(subject.success).to be_falsy expect(subject.result).to be :unauthorized expect(instance).to have_received(:refresh_token) @@ -536,15 +482,15 @@ end end - context 'with yield returning :unauthorized and the refresh returning with a :failure' do + context "with yield returning :unauthorized and the refresh returning with a :failure" do let(:yield_service_result) { ServiceResult.failure(result: :unauthorized) } let(:refresh_service_result) do - data = Storages::StorageErrorData.new(source: nil, payload: { error: 'invalid_request' }) + data = Storages::StorageErrorData.new(source: nil, payload: { error: "invalid_request" }) ServiceResult.failure(result: :error, errors: Storages::StorageError.new(code: :error, data:)) end - it 'returns a ServiceResult with success, without refresh' do + it "returns a ServiceResult with success, without refresh" do expect(subject.success).to be_falsy expect(subject.result).to be :error expect(instance).to have_received(:refresh_token) @@ -552,7 +498,7 @@ end end - context 'with yield returning :unauthorized first time and :success the second time' do + context "with yield returning :unauthorized first time and :success the second time" do let(:yield_double_object) { Object.new } let(:yield_service_result1) { ServiceResult.failure(result: :unauthorized) } let(:yield_service_result2) { ServiceResult.success } @@ -572,7 +518,7 @@ ) end - it 'returns a ServiceResult with success, without refresh' do + it "returns a ServiceResult with success, without refresh" do expect(subject.success).to be_truthy expect(subject).to be yield_service_result2 expect(instance).to have_received(:refresh_token) diff --git a/spec/services/oauth_clients/one_drive_connection_manager_spec.rb b/spec/services/oauth_clients/one_drive_connection_manager_spec.rb index 76ddc524188f..b04fa5de908a 100644 --- a/spec/services/oauth_clients/one_drive_connection_manager_spec.rb +++ b/spec/services/oauth_clients/one_drive_connection_manager_spec.rb @@ -28,19 +28,19 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" RSpec.describe OAuthClients::ConnectionManager, :webmock, type: :model do let(:user) { create(:user) } - let(:storage) { create(:one_drive_storage, :with_oauth_client, tenant_id: 'consumers') } + let(:storage) { create(:one_drive_storage, :with_oauth_client, tenant_id: "consumers") } let(:token) { create(:oauth_client_token, oauth_client: storage.oauth_client, user:) } subject(:connection_manager) do described_class.new(user:, configuration: storage.oauth_configuration) end - describe '#code_to_token' do - let(:code) { 'wow.such.code.much.token' } + describe "#code_to_token" do + let(:code) { "wow.such.code.much.token" } let(:code_to_token_response) do { access_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q...", @@ -69,64 +69,51 @@ end before do - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 200, body: code_to_token_response, headers: { 'Content-Type' => 'application/json' }) + stub_request(:post, "https://login.microsoftonline.com/consumers/oauth2/v2.0/token") + .to_return(status: 200, body: code_to_token_response, headers: { "Content-Type" => "application/json" }) - stub_request(:get, 'https://graph.microsoft.com/v1.0/me') + stub_request(:get, "https://graph.microsoft.com/v1.0/me") .with(headers: { Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q..." }) - .to_return(status: 200, body: me_response, headers: { 'Content-Type' => 'application/json' }) + .to_return(status: 200, body: me_response, headers: { "Content-Type" => "application/json" }) end - it 'fills in the origin_user_id' do + it "fills in the origin_user_id" do expect { subject.code_to_token(code) }.to change(OAuthClientToken, :count).by(1) last_token = OAuthClientToken .where(access_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q...") .last - expect(last_token.origin_user_id).to eq('87d349ed-44d7-43e1-9a83-5f2406dee5bd') + expect(last_token.origin_user_id).to eq("87d349ed-44d7-43e1-9a83-5f2406dee5bd") end - context 'when the identification request fails' do + context "when the identification request fails" do before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me') + stub_request(:get, "https://graph.microsoft.com/v1.0/me") .with(headers: { Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q..." }) .to_return(status: 404) end - it 'raises an error' do + it "raises an error" do expect { subject.code_to_token(code) }.to raise_error(HTTPX::HTTPError) end end end - describe '#get_authorization_uri' do - it 'always add the necessary scopes' do - uri = connection_manager.get_authorization_uri(state: nil) - - expect(uri).to include CGI.escape(storage.oauth_configuration.scope.join(' ')) - end - - it 'adds the state if present' do - uri = connection_manager.get_authorization_uri(state: 'https://some.site.com') - expect(uri).to include "&state=https" - end - end - - describe '#get_access_token' do + describe "#get_access_token" do subject(:access_token_result) { connection_manager.get_access_token } - context 'with no OAuthClientToken present' do - it 'returns a redirection URL' do + context "with no OAuthClientToken present" do + it "returns a redirection URL" do expect(access_token_result).to be_failure - expect(access_token_result.result).to eq(connection_manager.get_authorization_uri) + expect(access_token_result.result).to eq(storage.oauth_configuration.authorization_uri) end end - context 'with an OAuthClientToken present' do + context "with an OAuthClientToken present" do before { token } - it 'returns the OAuthClientToken' do + it "returns the OAuthClientToken" do expect(access_token_result).to be_truthy expect(access_token_result.result).to be_a OAuthClientToken # The one and only... expect(access_token_result.result).to eql token From 821c91ce02ebec2b575a76596cb9c1079e7de63f Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Thu, 21 Mar 2024 11:00:00 +0100 Subject: [PATCH 184/218] [#53369] fixed unit test setup --- app/controllers/oauth_clients_controller.rb | 37 ++- .../admin/project_storages_controller.rb | 7 +- .../api/v3/storages/storages_api_spec.rb | 258 +++++++++--------- .../oauth_access_grant_flow_spec.rb | 14 +- 4 files changed, 151 insertions(+), 165 deletions(-) diff --git a/app/controllers/oauth_clients_controller.rb b/app/controllers/oauth_clients_controller.rb index 48389a335a43..0dc0bfb1d6e1 100644 --- a/app/controllers/oauth_clients_controller.rb +++ b/app/controllers/oauth_clients_controller.rb @@ -78,25 +78,22 @@ def ensure_connection handle_absent_oauth_client unless oauth_client - connection_manager = OAuthClients::ConnectionManager.new( - user: User.current, - configuration: oauth_client.integration.oauth_configuration - ) - + storage = oauth_client.integration # check if the origin is the same - destination_url = if params.fetch(:destination_url, '').start_with?(root_url) + destination_url = if params.fetch(:destination_url, "").start_with?(root_url) params[:destination_url] else root_url end auth_state = ::Storages::Peripherals::StorageInteraction::Authentication - .authorization_state(storage: oauth_client.integration, user: User.current) + .authorization_state(storage:, user: User.current) + if auth_state == :connected redirect_to(destination_url) else nonce = SecureRandom.uuid cookies["oauth_state_#{nonce}"] = { value: { href: destination_url, storageId: storage_id }.to_json, expires: 1.hour } - redirect_to(connection_manager.get_authorization_uri(state: nonce)) + redirect_to(storage.oauth_configuration.authorization_uri(state: nonce)) end end @@ -105,8 +102,8 @@ def ensure_connection private def handle_absent_oauth_client - flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'), - I18n.t('oauth_client.errors.oauth_client_not_found_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_client_not_found"), + I18n.t("oauth_client.errors.oauth_client_not_found_explanation")] if User.current.admin? redirect_to admin_settings_storages_path @@ -128,9 +125,9 @@ def set_oauth_errors(service_result) service_result.errors = service_result.errors.to_active_model_errors end - flash[:error] = ["#{t(:'oauth_client.errors.oauth_authorization_code_grant_had_errors')}:"] + flash[:error] = ["#{t(:"oauth_client.errors.oauth_authorization_code_grant_had_errors")}:"] service_result.errors.each do |error| - flash[:error] << "#{t(:'oauth_client.errors.oauth_reported')}: #{error.full_message}" + flash[:error] << "#{t(:"oauth_client.errors.oauth_reported")}: #{error.full_message}" end end @@ -141,8 +138,8 @@ def set_code @code = params[:code] if @code.blank? - flash[:error] = [I18n.t('oauth_client.errors.oauth_code_not_present'), - I18n.t('oauth_client.errors.oauth_code_not_present_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_code_not_present"), + I18n.t("oauth_client.errors.oauth_code_not_present_explanation")] redirect_user_or_admin(get_redirect_uri) do # If the current user is an admin, we send her directly to the @@ -164,8 +161,8 @@ def set_redirect_uri else # To protect against CSRF we cancel this request. There was either no # state parameter given, or there was no corresponding cookie present. - flash[:error] = [I18n.t('oauth_client.errors.oauth_state_not_present'), - I18n.t('oauth_client.errors.oauth_state_not_present_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_state_not_present"), + I18n.t("oauth_client.errors.oauth_state_not_present_explanation")] redirect_user_or_admin(nil) do # Guide the user to the settings that she needs to edit/fix. @@ -200,11 +197,11 @@ def find_oauth_client # This happens during admin setup if the user forgot to update the return_uri # on the Authorization Server (i.e. Nextcloud) after updating the OpenProject # side with a new client_id and client_secret. - flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'), - I18n.t('oauth_client.errors.oauth_client_not_found_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_client_not_found"), + I18n.t("oauth_client.errors.oauth_client_not_found_explanation")] redirect_user_or_admin(get_redirect_uri) do - # Something must be wrong in the storage's setup + # Something must be wrong in the storage"s setup redirect_to admin_settings_storages_path end end @@ -217,7 +214,7 @@ def redirect_user_or_admin(redirect_uri = nil) if User.current.admin && redirect_uri && (nextcloud? || one_drive?) yield elsif redirect_uri - flash[:error] = [t(:'oauth_client.errors.oauth_issue_contact_admin')] + flash[:error] = [t(:"oauth_client.errors.oauth_issue_contact_admin")] redirect_to redirect_uri else redirect_to root_url diff --git a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb index 890f7307b04a..9f97c9e249a6 100644 --- a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb @@ -60,6 +60,7 @@ def index # Show a HTML page with a form in order to create a new ProjectStorage # Called by: When a user clicks on the "+New" button in Project -> Settings -> File Storages + # rubocop:disable Metrics/AbcSize def new @available_storages = available_storages project_folder_mode = Storages::ProjectStorage.project_folder_modes.values.find do |mode| @@ -76,6 +77,8 @@ def new render template: "/storages/project_settings/new" end + # rubocop:enable Metrics/AbcSize + # Create a new ProjectStorage object. # Called by: The new page above with form-data from that form. def create @@ -113,7 +116,7 @@ def oauth_access_grant # rubocop:disable Metrics/AbcSize end end - # Edit page is very similar to new page, except that we don"t need to set + # Edit page is very similar to new page, except that we don't need to set # default attribute values because the object already exists # Called by: Global app/config/routes.rb to serve Web page def edit @@ -149,7 +152,7 @@ def update end # Purpose: Destroy a ProjectStorage object - # Called by: By pressing a "Delete" icon in the Project"s settings ProjectStorages page + # Called by: By pressing a "Delete" icon in the Project's settings ProjectStorages page # It redirects back to the list of ProjectStorages in the project def destroy # The complex logic for deleting associated objects was moved into a service: diff --git a/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb b/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb index 31011698613e..fccff3ac72d3 100644 --- a/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb +++ b/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb @@ -26,10 +26,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" require_module_spec_helper -RSpec.describe 'API v3 storages resource', :webmock, content_type: :json do +RSpec.describe "API v3 storages resource", :webmock, content_type: :json do include API::V3::Utilities::PathHelper include StorageServerHelpers include UserPermissionsHelper @@ -42,9 +42,7 @@ end shared_let(:user_without_project) { create(:user) } shared_let(:admin) { create(:admin) } - shared_let(:oauth_application) { create(:oauth_application) } - shared_let(:oauth_client) { create(:oauth_client) } - shared_let(:storage) { create(:nextcloud_storage, creator: user_with_permissions, oauth_application:, oauth_client:) } + shared_let(:storage) { create(:nextcloud_storage_configured, creator: user_with_permissions) } shared_let(:project_storage) { create(:project_storage, project:, storage:) } let(:current_user) { user_with_permissions } @@ -55,65 +53,61 @@ end before do - Storages::Peripherals::Registry.stub( - "#{storage.short_provider_type}.queries.auth_check", - ->(_) { auth_check_result } - ) - + Storages::Peripherals::Registry.stub("nextcloud.queries.auth_check", ->(_) { auth_check_result }) login_as current_user end - shared_examples_for 'successful storage response' do |as_admin: false| - include_examples 'successful response' + shared_examples_for "successful storage response" do |as_admin: false| + include_examples "successful response" - describe 'response body' do + describe "response body" do subject { last_response.body } - it { is_expected.to be_json_eql('Storage'.to_json).at_path('_type') } - it { is_expected.to be_json_eql(storage.id.to_json).at_path('id') } + it { is_expected.to be_json_eql("Storage".to_json).at_path("_type") } + it { is_expected.to be_json_eql(storage.id.to_json).at_path("id") } if as_admin - it { is_expected.to have_json_path('_embedded/oauthApplication') } + it { is_expected.to have_json_path("_embedded/oauthApplication") } else - it { is_expected.not_to have_json_path('_embedded/oauthApplication') } + it { is_expected.not_to have_json_path("_embedded/oauthApplication") } end end end - describe 'GET /api/v3/storages' do + describe "GET /api/v3/storages" do let(:path) { api_v3_paths.storages } let!(:another_storage) { create(:nextcloud_storage) } subject(:last_response) { get path } - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'gets the storage collection and returns it' do + describe "gets the storage collection and returns it" do subject { last_response.body } - it_behaves_like 'API V3 collection response', 2, 2, 'Storage', 'Collection' do + it_behaves_like "API V3 collection response", 2, 2, "Storage", "Collection" do let(:elements) { [another_storage, storage] } end end end - context 'as non-admin' do - describe 'gets the storage collection of storages linked to visible projects with correct permissions' do + context "as non-admin" do + describe "gets the storage collection of storages linked to visible projects with correct permissions" do subject { last_response.body } - it_behaves_like 'API V3 collection response', 1, 1, 'Storage', 'Collection' do + it_behaves_like "API V3 collection response", 1, 1, "Storage", "Collection" do let(:elements) { [storage] } end end end end - describe 'POST /api/v3/storages' do + describe "POST /api/v3/storages" do let(:path) { api_v3_paths.storages } - let(:host) { 'https://example.nextcloud.local' } - let(:name) { 'APIStorage' } - let(:type) { 'urn:openproject-org:api:v3:storages:Nextcloud' } + let(:host) { "https://example.nextcloud.local" } + let(:name) { "APIStorage" } + let(:type) { "urn:openproject-org:api:v3:storages:Nextcloud" } let(:params) do { name:, @@ -134,32 +128,32 @@ post path, params.to_json end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'creates a storage and returns it' do + describe "creates a storage and returns it" do subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to have_json_path('_embedded/oauthApplication/clientSecret') } + it { is_expected.to have_json_path("_embedded/oauthApplication/clientSecret") } end - context 'with applicationPassword' do + context "with applicationPassword" do let(:params) do super().merge( - applicationPassword: 'myappsecret' + applicationPassword: "myappsecret" ) end subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to be_json_eql('true').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("true").at_path("hasApplicationPassword") } end - context 'with applicationPassword as null' do + context "with applicationPassword as null" do let(:params) do super().merge( applicationPassword: nil @@ -168,164 +162,164 @@ subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to be_json_eql('false').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("false").at_path("hasApplicationPassword") } end - context 'if missing a mandatory value' do + context "if missing a mandatory value" do let(:params) do { - name: 'APIStorage', + name: "APIStorage", _links: { - type: { href: 'urn:openproject-org:api:v3:storages:Nextcloud' } + type: { href: "urn:openproject-org:api:v3:storages:Nextcloud" } } } end - it_behaves_like 'constraint violation' do + it_behaves_like "constraint violation" do let(:message) { "Host is not a valid URL" } end end end - context 'as non-admin' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + it_behaves_like "unauthorized access" end end - describe 'GET /api/v3/storages/:storage_id' do + describe "GET /api/v3/storages/:storage_id" do let(:path) { api_v3_paths.storage(storage.id) } - context 'if user belongs to a project using the given storage' do + context "if user belongs to a project using the given storage" do subject { last_response.body } - it_behaves_like 'successful storage response' + it_behaves_like "successful storage response" - context 'if user is missing permission view_file_links' do + context "if user is missing permission view_file_links" do before(:all) { remove_permissions(user_with_permissions, :view_file_links) } after(:all) { add_permissions(user_with_permissions, :view_file_links) } - it_behaves_like 'not found' + it_behaves_like "not found" end - context 'if no storage with that id exists' do + context "if no storage with that id exists" do let(:path) { api_v3_paths.storage(1337) } - it_behaves_like 'not found' + it_behaves_like "not found" end end - context 'if user has :manage_storages_in_project permission in any project' do + context "if user has :manage_storages_in_project permission in any project" do let(:permissions) { %i(manage_storages_in_project) } - it_behaves_like 'successful storage response' + it_behaves_like "successful storage response" end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - it_behaves_like 'successful storage response', as_admin: true + it_behaves_like "successful storage response", as_admin: true subject { last_response.body } - it { is_expected.not_to have_json_path('_embedded/oauthApplication/clientSecret') } + it { is_expected.not_to have_json_path("_embedded/oauthApplication/clientSecret") } end - context 'when OAuth authorization server is involved' do - shared_examples 'a storage authorization result' do |expected:, has_authorize_link:| + context "when OAuth authorization server is involved" do + shared_examples "a storage authorization result" do |expected:, has_authorize_link:| subject { last_response.body } it "returns #{expected}" do - expect(subject).to be_json_eql(expected.to_json).at_path('_links/authorizationState/href') + expect(subject).to be_json_eql(expected.to_json).at_path("_links/authorizationState/href") end it "has #{has_authorize_link ? '' : 'no'} authorize link" do if has_authorize_link - expect(subject).to have_json_path('_links/authorize/href') + expect(subject).to have_json_path("_links/authorize/href") else - expect(subject).not_to have_json_path('_links/authorize/href') + expect(subject).not_to have_json_path("_links/authorize/href") end end end - context 'when authorization succeeds and storage is connected' do + context "when authorization succeeds and storage is connected" do let(:auth_check_result) { ServiceResult.success } - include_examples 'a storage authorization result', + include_examples "a storage authorization result", expected: API::V3::Storages::URN_CONNECTION_CONNECTED, has_authorize_link: false end - context 'when authorization fails' do + context "when authorization fails" do let(:auth_check_result) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } - include_examples 'a storage authorization result', + include_examples "a storage authorization result", expected: API::V3::Storages::URN_CONNECTION_AUTH_FAILED, has_authorize_link: true end - context 'when authorization fails with an error' do + context "when authorization fails with an error" do let(:auth_check_result) { ServiceResult.failure(errors: Storages::StorageError.new(code: :error)) } - include_examples 'a storage authorization result', + include_examples "a storage authorization result", expected: API::V3::Storages::URN_CONNECTION_ERROR, has_authorize_link: false end end end - describe 'PATCH /api/v3/storages/:storage_id' do + describe "PATCH /api/v3/storages/:storage_id" do let(:path) { api_v3_paths.storage(storage.id) } - let(:name) { 'A new storage name' } + let(:name) { "A new storage name" } let(:params) { { name: } } subject(:last_response) do patch path, params.to_json end - context 'as non-admin' do - context 'if user belongs to a project using the given storage' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + context "if user belongs to a project using the given storage" do + it_behaves_like "unauthorized access" end - context 'if user does not belong to a project using the given storage' do + context "if user does not belong to a project using the given storage" do let(:current_user) { user_without_project } - it_behaves_like 'not found' + it_behaves_like "not found" end end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'patches the storage and returns it' do + describe "patches the storage and returns it" do subject { last_response.body } - it_behaves_like 'successful response' + it_behaves_like "successful response" - it { is_expected.to be_json_eql(name.to_json).at_path('name') } + it { is_expected.to be_json_eql(name.to_json).at_path("name") } end - context 'with applicationPassword' do + context "with applicationPassword" do let(:params) do super().merge( - applicationPassword: 'myappsecret' + applicationPassword: "myappsecret" ) end before do - mock_nextcloud_application_credentials_validation(storage.host, password: 'myappsecret') + mock_nextcloud_application_credentials_validation(storage.host, password: "myappsecret") end subject { last_response.body } - it_behaves_like 'successful response' + it_behaves_like "successful response" - it { is_expected.to be_json_eql('true').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("true").at_path("hasApplicationPassword") } end - context 'with applicationPassword as null' do + context "with applicationPassword as null" do let(:params) do super().merge( applicationPassword: nil @@ -334,30 +328,30 @@ subject { last_response.body } - it_behaves_like 'successful response' + it_behaves_like "successful response" - it { is_expected.to be_json_eql('false').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("false").at_path("hasApplicationPassword") } end - context 'with invalid applicationPassword' do + context "with invalid applicationPassword" do let(:params) do super().merge( - applicationPassword: '123' + applicationPassword: "123" ) end before do - mock_nextcloud_application_credentials_validation(storage.host, password: '123', response_code: 401) + mock_nextcloud_application_credentials_validation(storage.host, password: "123", response_code: 401) end subject { last_response.body } - it { is_expected.to be_json_eql('Password is not valid.'.to_json).at_path('message') } + it { is_expected.to be_json_eql("Password is not valid.".to_json).at_path("message") } end end end - describe 'DELETE /api/v3/storages/:storage_id' do + describe "DELETE /api/v3/storages/:storage_id" do let(:path) { api_v3_paths.storage(storage.id) } let(:delete_folder_url) do "#{storage.host}/remote.php/dav/files/#{storage.username}/#{project_storage.managed_project_folder_path.chop}/" @@ -374,72 +368,72 @@ deletion_request_stub end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - it_behaves_like 'successful no content response' + it_behaves_like "successful no content response" end - context 'as non-admin' do - context 'if user belongs to a project using the given storage' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + context "if user belongs to a project using the given storage" do + it_behaves_like "unauthorized access" - it 'does not request project folder deletion' do + it "does not request project folder deletion" do expect(deletion_request_stub).not_to have_been_requested end end - context 'if user does not belong to a project using the given storage' do + context "if user does not belong to a project using the given storage" do let(:current_user) { user_without_project } - it_behaves_like 'not found' + it_behaves_like "not found" - it 'does not request project folder deletion' do + it "does not request project folder deletion" do expect(deletion_request_stub).not_to have_been_requested end end end end - describe 'GET /api/v3/storages/:storage_id/open' do + describe "GET /api/v3/storages/:storage_id/open" do let(:path) { api_v3_paths.storage_open(storage.id) } - let(:location) { 'https://deathstar.storage.org/files' } + let(:location) { "https://deathstar.storage.org/files" } before do Storages::Peripherals::Registry.stub( - 'nextcloud.queries.open_storage', + "nextcloud.queries.open_storage", ->(_) { ServiceResult.success(result: location) } ) end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - it_behaves_like 'redirect response' + it_behaves_like "redirect response" end - context 'if user belongs to a project using the given storage' do - it_behaves_like 'redirect response' + context "if user belongs to a project using the given storage" do + it_behaves_like "redirect response" - context 'if user is missing permission view_file_links' do + context "if user is missing permission view_file_links" do before(:all) { remove_permissions(user_with_permissions, :view_file_links) } after(:all) { add_permissions(user_with_permissions, :view_file_links) } - it_behaves_like 'not found' + it_behaves_like "not found" end - context 'if no storage with that id exists' do - let(:path) { api_v3_paths.storage_open('1337') } + context "if no storage with that id exists" do + let(:path) { api_v3_paths.storage_open("1337") } - it_behaves_like 'not found' + it_behaves_like "not found" end end end - describe 'POST /api/v3/storages/:storage_id/oauth_client_credentials' do + describe "POST /api/v3/storages/:storage_id/oauth_client_credentials" do let(:path) { api_v3_paths.storage_oauth_client_credentials(storage.id) } - let(:client_id) { 'myl1ttlecl13ntidii' } - let(:client_secret) { 'th3v3rys3cr3tcl13nts3cr3t' } + let(:client_id) { "myl1ttlecl13ntidii" } + let(:client_secret) { "th3v3rys3cr3tcl13nts3cr3t" } let(:params) do { clientId: client_id, @@ -451,41 +445,41 @@ post path, params.to_json end - context 'as non-admin' do - context 'if user belongs to a project using the given storage' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + context "if user belongs to a project using the given storage" do + it_behaves_like "unauthorized access" end - context 'if user does not belong to a project using the given storage' do + context "if user does not belong to a project using the given storage" do let(:current_user) { user_without_project } - it_behaves_like 'not found' + it_behaves_like "not found" end end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'creates new oauth client secrets' do + describe "creates new oauth client secrets" do subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to be_json_eql('OAuthClientCredentials'.to_json).at_path('_type') } - it { is_expected.to be_json_eql(client_id.to_json).at_path('clientId') } - it { is_expected.to be_json_eql(true.to_json).at_path('confidential') } - it { is_expected.not_to have_json_path('clientSecret') } + it { is_expected.to be_json_eql("OAuthClientCredentials".to_json).at_path("_type") } + it { is_expected.to be_json_eql(client_id.to_json).at_path("clientId") } + it { is_expected.to be_json_eql(true.to_json).at_path("confidential") } + it { is_expected.not_to have_json_path("clientSecret") } end - context 'if request body is invalid' do + context "if request body is invalid" do let(:params) do { - clientSecret: 'only_an_id' + clientSecret: "only_an_id" } end - it_behaves_like 'constraint violation' do - let(:message) { 'Client ID can\'t be blank.' } + it_behaves_like "constraint violation" do + let(:message) { "Client ID can't be blank." } end end end diff --git a/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb b/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb index 86647c0c511b..41f5f901ede4 100644 --- a/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb +++ b/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb @@ -62,7 +62,7 @@ context "when user is logged in" do before { login_as(user) } - context 'when user is not "connected"' do + context "when user is not 'connected'" do let(:nonce) { "57a17c3f-b2ed-446e-9dd8-651ba3aec37d" } let(:redirect_uri) do CGI.escape("#{OpenProject::Application.root_url}/oauth_clients/#{storage.oauth_client.client_id}/callback") @@ -90,19 +90,11 @@ end end - context 'when user is "connected"' do + context "when user is 'connected'" do shared_let(:oauth_client_token) { create(:oauth_client_token, oauth_client: storage.oauth_client, user:) } before do - oauth_client_token - stub_request(:get, "#{storage.host}/ocs/v1.php/cloud/user") - .with( - headers: { - "Accept" => "application/json", - "Authorization" => "Bearer #{oauth_client_token.access_token}", - "Ocs-Apirequest" => "true" - } - ).to_return(status: 200, body: "", headers: {}) + Storages::Peripherals::Registry.stub("nextcloud.queries.auth_check", ->(_) { ServiceResult.success }) end it "redirects to destination_url" do From 5f5145da05dfe2f8728d215e2d015e6e7826b3e2 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Thu, 21 Mar 2024 13:44:00 +0100 Subject: [PATCH 185/218] [#53369] fixed feature spec --- .../spec/features/create_file_links_spec.rb | 14 ++++------- .../spec/features/show_file_links_spec.rb | 25 ++++++------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/modules/storages/spec/features/create_file_links_spec.rb b/modules/storages/spec/features/create_file_links_spec.rb index e08534c17fe6..2b87c0286ed7 100644 --- a/modules/storages/spec/features/create_file_links_spec.rb +++ b/modules/storages/spec/features/create_file_links_spec.rb @@ -61,15 +61,11 @@ before do allow(Storages::FileLinkSyncService).to receive(:new).and_return(sync_service) - stub_request(:get, "#{storage.host}/ocs/v1.php/cloud/user") - .with( - headers: { - "Authorization" => "Bearer #{oauth_client_token.access_token}", - "Ocs-Apirequest" => "true", - "Accept" => "application/json" - } - ) - .to_return(status: 200, body: "", headers: {}) + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { ServiceResult.success } + ) + stub_request(:propfind, "#{storage.host}/remote.php/dav/files/#{oauth_client_token.origin_user_id}/") .to_return(status: 207, body: root_xml_response, headers: {}) stub_request(:propfind, "#{storage.host}/remote.php/dav/files/#{oauth_client_token.origin_user_id}/Folder1") diff --git a/modules/storages/spec/features/show_file_links_spec.rb b/modules/storages/spec/features/show_file_links_spec.rb index 3cad19dd1f93..d96689525f09 100644 --- a/modules/storages/spec/features/show_file_links_spec.rb +++ b/modules/storages/spec/features/show_file_links_spec.rb @@ -37,25 +37,21 @@ let(:current_user) { create(:user, member_with_permissions: { project => permissions }) } let(:work_package) { create(:work_package, project:, description: "Initial description") } - let(:oauth_application) { create(:oauth_application) } - let(:storage) { create(:nextcloud_storage, name: "My storage", oauth_application:) } + let(:storage) { create(:nextcloud_storage_configured, name: "My storage") } let(:oauth_client) { create(:oauth_client, integration: storage) } let(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user: current_user) } let(:project_storage) { create(:project_storage, project:, storage:) } let(:file_link) { create(:file_link, container: work_package, storage:, origin_id: "42", origin_name: "logo.png") } let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } let(:sync_service) { instance_double(Storages::FileLinkSyncService) } + let(:authorization_state) { ServiceResult.success } before do - allow(OAuthClients::ConnectionManager) - .to receive(:new) - .and_return(connection_manager) - allow(connection_manager) - .to receive_messages(refresh_token: ServiceResult.success(result: oauth_client_token), - get_access_token: ServiceResult.success(result: oauth_client_token), - authorization_state: :connected) + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { authorization_state } + ) # Mock FileLinkSyncService as if Nextcloud would respond with origin_status=nil allow(Storages::FileLinkSyncService) @@ -98,10 +94,7 @@ end context "if user is not authorized in Nextcloud" do - before do - allow(connection_manager).to receive_messages(authorization_state: :failed_authorization, - get_authorization_uri: "https://example.com/authorize") - end + let(:authorization_state) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } it "must show storage information box with login button" do within_test_selector("op-tab-content--tab-section", text: "MY STORAGE", wait: 25) do @@ -113,9 +106,7 @@ end context "if an error occurred while authorizing to Nextcloud" do - before do - allow(connection_manager).to receive(:authorization_state).and_return(:error) - end + let(:authorization_state) { ServiceResult.failure(errors: Storages::StorageError.new(code: :error)) } it "must show storage information box" do within_test_selector("op-tab-content--tab-section", text: "MY STORAGE", wait: 25) do From 61b19b8a1e2d11185e3f9632bcdcc5c72c50cfdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 20 Mar 2024 13:45:48 +0100 Subject: [PATCH 186/218] Use separate job for extracting version --- .github/workflows/docker.yml | 45 ++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 937c42cd3c66..2e59e1cd3bf6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,7 +21,41 @@ env: REGISTRY_IMAGE: openproject/community jobs: + extract_version: + runs-on: ubuntu-latest + steps: + - name: Extract version + id: extract_version + run: | + if [[ ${{ github.event_name }} == 'push' ]]; then + TAG_REF=${GITHUB_REF#refs/tags/} + CHECKOUT_REF=$GITHUB_REF + elif [[ ${{ github.event_name }} == 'schedule' ]]; then + TAG_REF=dev + CHECKOUT_REF=refs/heads/dev + elif [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then + TAG_REF=${{ inputs.tag }} + CHECKOUT_REF=${{ inputs.tag }} + else + echo "Unsupported event" + exit 1 + fi + + if [ -z "$TAG_REF" ] || [ -z "$CHECKOUT_REF" ]; then + echo "No TAG_REF or CHECKOUT_REF set. Aborting" + exit 1 + fi + + VERSION=${TAG_REF#v} + echo "Version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" + outputs: + version: ${{ steps.extract_version.outputs.version }} + checkout_ref: ${{ steps.extract_version.outputs.checkout_ref }} build: + needs: + - extract_version if: github.repository == 'opf/openproject' runs-on: [ self-hosted, aws, ubuntu22, x64, 2XL ] strategy: @@ -48,7 +82,7 @@ jobs: elif [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then TAG_REF=${{ inputs.tag }} fi - + VERSION=${TAG_REF#v} echo "Version: $VERSION" echo "::set-output name=version::$VERSION" @@ -78,7 +112,7 @@ jobs: uses: docker/metadata-action@v4 with: tags: | - type=semver,pattern={{version}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} images: | ${{ env.REGISTRY_IMAGE }} - name: Build image @@ -139,6 +173,7 @@ jobs: matrix: target: [slim, all-in-one] needs: + - extract_version - build steps: - name: Download digests @@ -167,9 +202,9 @@ jobs: latest=false suffix=${{ steps.set_suffix.outputs.suffix }} tags: | - type=semver,pattern={{version}},value=${{ steps.extract_version.outputs.version }} - type=semver,pattern={{major}}.{{minor}},value=${{ steps.extract_version.outputs.version }} - type=semver,pattern={{major}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.extract_version.outputs.version }} + type=semver,pattern={{major}},value=${{ needs.extract_version.outputs.version }} type=raw,value=dev,priority=200,enable={{is_default_branch}} - name: Login to Docker Hub uses: docker/login-action@v2 From e551472e40ff74ec15ee978a021518c938822a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 20 Mar 2024 14:19:08 +0100 Subject: [PATCH 187/218] use git context for correct sha --- .github/workflows/docker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2e59e1cd3bf6..82b22536b97d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -111,6 +111,7 @@ jobs: id: meta uses: docker/metadata-action@v4 with: + context: git tags: | type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} images: | @@ -193,6 +194,7 @@ jobs: id: meta uses: docker/metadata-action@v4 with: + context: git images: ${{ env.REGISTRY_IMAGE }} labels: | io.artifacthub.package.readme-url="https://www.openproject.org/docs/installation-and-operations/installation/docker/" From 08e05ca9c1c6dd8bb563679edd5b5b847ecadd42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 20 Mar 2024 14:20:41 +0100 Subject: [PATCH 188/218] Add readme-url to individual containers --- .github/workflows/docker.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 82b22536b97d..e07e8d996d7b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -112,6 +112,10 @@ jobs: uses: docker/metadata-action@v4 with: context: git + labels: | + io.artifacthub.package.readme-url="https://www.openproject.org/docs/installation-and-operations/installation/docker/" + org.opencontainers.image.documentation="https://www.openproject.org/docs/" + org.opencontainers.image.vendor="OpenProject GmbH" tags: | type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} images: | From 664594ce9273ec44968a1bc98e10af61a638fd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 20 Mar 2024 14:23:46 +0100 Subject: [PATCH 189/218] Remove double quotes --- .github/workflows/docker.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e07e8d996d7b..f7a0acc0ca41 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -113,9 +113,9 @@ jobs: with: context: git labels: | - io.artifacthub.package.readme-url="https://www.openproject.org/docs/installation-and-operations/installation/docker/" - org.opencontainers.image.documentation="https://www.openproject.org/docs/" - org.opencontainers.image.vendor="OpenProject GmbH" + io.artifacthub.package.readme-url=https://www.openproject.org/docs/installation-and-operations/installation/docker/ + org.opencontainers.image.documentation=https://www.openproject.org/docs/ + org.opencontainers.image.vendor=OpenProject GmbH tags: | type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} images: | @@ -201,9 +201,9 @@ jobs: context: git images: ${{ env.REGISTRY_IMAGE }} labels: | - io.artifacthub.package.readme-url="https://www.openproject.org/docs/installation-and-operations/installation/docker/" - org.opencontainers.image.documentation="https://www.openproject.org/docs/" - org.opencontainers.image.vendor="OpenProject GmbH" + io.artifacthub.package.readme-url=https://www.openproject.org/docs/installation-and-operations/installation/docker/ + org.opencontainers.image.documentation=https://www.openproject.org/docs/ + org.opencontainers.image.vendor=OpenProject GmbH flavor: | latest=false suffix=${{ steps.set_suffix.outputs.suffix }} From 7a51608f969d22d07e198c6f4dffda9aea4e98e2 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Thu, 21 Mar 2024 11:27:04 +0000 Subject: [PATCH 190/218] we're not in the repo during the merge --- .github/workflows/docker.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f7a0acc0ca41..ae67f7e8d163 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -198,7 +198,6 @@ jobs: id: meta uses: docker/metadata-action@v4 with: - context: git images: ${{ env.REGISTRY_IMAGE }} labels: | io.artifacthub.package.readme-url=https://www.openproject.org/docs/installation-and-operations/installation/docker/ From eda9bc89a68a4411ea44ca010423767dcc7d2a39 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 21 Mar 2024 12:24:36 +0100 Subject: [PATCH 191/218] robustness for project lists on deleted custom fields --- app/helpers/projects_helper.rb | 1 + .../queries/projects/selects/custom_field.rb | 4 ++ .../queries/selects/available_selects.rb | 9 ++- app/models/queries/selects/base.rb | 4 ++ spec/models/queries/projects/factory_spec.rb | 67 ++++++++++++++----- .../set_attributes_service_spec.rb | 14 ++++ 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e69ab162878d..ef988b66921b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -63,6 +63,7 @@ def selected_projects_columns_options Setting .enabled_projects_columns .map { |c| projects_columns_options.find { |o| o[:id].to_s == c } } + .compact end def protected_projects_columns_options diff --git a/app/models/queries/projects/selects/custom_field.rb b/app/models/queries/projects/selects/custom_field.rb index 1ccd53186a65..57853e8d1cd9 100644 --- a/app/models/queries/projects/selects/custom_field.rb +++ b/app/models/queries/projects/selects/custom_field.rb @@ -55,4 +55,8 @@ def custom_field .visible .find_by(id: self.class.key.match(attribute)[1]) end + + def available? + custom_field.present? + end end diff --git a/app/models/queries/selects/available_selects.rb b/app/models/queries/selects/available_selects.rb index 54224313581a..9082a126f1bc 100644 --- a/app/models/queries/selects/available_selects.rb +++ b/app/models/queries/selects/available_selects.rb @@ -30,7 +30,14 @@ module Queries module Selects module AvailableSelects def select_for(key) - (find_available_select(key) || ::Queries::Selects::NotExistingSelect).new(key.to_sym) + select = (find_available_select(key) || ::Queries::Selects::NotExistingSelect) + .new(key.to_sym) + + # It might be that while the class of selects is available, the instantiated select isn't. + # This can e.g. be the case for custom fields that had once been available and have a key that + # leads to them being found by the find_available_select but when instantiated, the custom + # field they refer to is no longer available. + select.available? ? select : ::Queries::Selects::NotExistingSelect.new(key.to_sym) end def available_selects diff --git a/app/models/queries/selects/base.rb b/app/models/queries/selects/base.rb index 8dc937d3154d..42545974ae01 100644 --- a/app/models/queries/selects/base.rb +++ b/app/models/queries/selects/base.rb @@ -55,4 +55,8 @@ def caption def initialize(attribute) self.attribute = attribute end + + def available? + true + end end diff --git a/spec/models/queries/projects/factory_spec.rb b/spec/models/queries/projects/factory_spec.rb index 58b1a159d1fe..4e3eb604f38f 100644 --- a/spec/models/queries/projects/factory_spec.rb +++ b/spec/models/queries/projects/factory_spec.rb @@ -50,6 +50,24 @@ query.select(:project_status, :name, :created_at) end end + let(:custom_field) do + build_stubbed(:project_custom_field, id: 1) do |cf| + scope = instance_double(ActiveRecord::Relation) + + allow(ProjectCustomField) + .to receive(:visible) + .and_return(scope) + + allow(scope) + .to receive(:find_by) + .and_return nil + + allow(scope) + .to receive(:find_by) + .with(id: cf.id.to_s) + .and_return(cf) + end + end let(:id) { nil } let(:params) { {} } @@ -80,7 +98,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -91,7 +109,11 @@ with_settings: { enabled_projects_columns: %w[name created_at cf_1] } do current_user { build_stubbed(:admin) } - it 'has the enabled project columns columns as selects' do + before do + custom_field + end + + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -120,7 +142,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -149,7 +171,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -178,7 +200,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -207,7 +229,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -236,7 +258,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -265,7 +287,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -383,7 +405,7 @@ .to eq([['id', :asc], ['name', :desc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -428,7 +450,7 @@ .to eq([%i[lft asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -625,6 +647,19 @@ .to be_nil end end + + context "without id, as non admin and with a non existing custom field id", + with_ee: %i[custom_fields_in_projects_list], + with_settings: { enabled_projects_columns: %w[name created_at cf_1 cf_42] } do + before do + custom_field + end + + it "has only the available fields (non admin only and only existing cf)" do + expect(find.selects.map(&:attribute)) + .to eq(%i[name cf_1]) # rubocop:disable Naming/VariableNumber + end + end end describe '.static_query_active' do @@ -650,7 +685,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -679,7 +714,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -708,7 +743,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -737,7 +772,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -766,7 +801,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -795,7 +830,7 @@ .to eq([['lft', :asc]]) end - it 'has the enabled project columns columns as selects' do + it 'has the enabled_project_columns columns as selects' do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end diff --git a/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb b/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb index 76866cf7b541..f4e8f79cdf0e 100644 --- a/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb +++ b/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb @@ -30,6 +30,20 @@ RSpec.describe Queries::Projects::ProjectQueries::SetAttributesService, type: :model do let(:current_user) { build_stubbed(:user) } + let!(:custom_field) do + build_stubbed(:project_custom_field, id: 1) do |cf| + scope = instance_double(ActiveRecord::Relation) + + allow(ProjectCustomField) + .to receive(:visible) + .and_return(scope) + + allow(scope) + .to receive(:find_by) + .with(id: cf.id.to_s) + .and_return(cf) + end + end let(:contract_instance) do contract = instance_double(Queries::Projects::ProjectQueries::CreateContract) From fe2c4955181d796014940d8c3dc4e9bea9d114ad Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Thu, 21 Mar 2024 16:02:27 +0100 Subject: [PATCH 192/218] [#53369] fixed small comment typo --- app/controllers/oauth_clients_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/oauth_clients_controller.rb b/app/controllers/oauth_clients_controller.rb index 0dc0bfb1d6e1..4a9823bc4277 100644 --- a/app/controllers/oauth_clients_controller.rb +++ b/app/controllers/oauth_clients_controller.rb @@ -201,7 +201,7 @@ def find_oauth_client I18n.t("oauth_client.errors.oauth_client_not_found_explanation")] redirect_user_or_admin(get_redirect_uri) do - # Something must be wrong in the storage"s setup + # Something must be wrong in the storage's setup redirect_to admin_settings_storages_path end end From d653ce6e4c0b96519324366749dc4da50f7dbef0 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 21 Mar 2024 16:22:28 +0100 Subject: [PATCH 193/218] [53584] Fix Portuguese languages mapping https://community.openproject.org/wp/53584 https://community.openproject.org/wp/53586 When making Portuguese Brazilian and Portuguese Portugal translations apart from each other in https://community.openproject.org/wp/53374, we missed that the root key in the `pt-BR.yml` and `pt-PT.yml` files is still `pt`. The backend is gracefully falling back to the `pt` translations but the frontend just fails to translate and falls back to the English strings, leading to mixed English and Portuguese in the user interface. A script fixes the root key in the `pt-BR.yml` and `pt-PT.yml` files. This script is executed each time the translations are updated from crowdin. --- .github/workflows/crowdin.yml | 3 +++ config/locales/crowdin/js-pt-BR.yml | 2 +- config/locales/crowdin/js-pt-PT.yml | 2 +- config/locales/crowdin/pt-BR.seeders.yml | 2 +- config/locales/crowdin/pt-BR.yml | 2 +- config/locales/crowdin/pt-PT.seeders.yml | 2 +- config/locales/crowdin/pt-PT.yml | 2 +- modules/avatars/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/avatars/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/avatars/config/locales/crowdin/pt-BR.yml | 2 +- modules/avatars/config/locales/crowdin/pt-PT.yml | 2 +- modules/backlogs/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/backlogs/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/backlogs/config/locales/crowdin/pt-BR.yml | 2 +- modules/backlogs/config/locales/crowdin/pt-PT.yml | 2 +- modules/bim/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/bim/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/bim/config/locales/crowdin/pt-BR.seeders.yml | 2 +- modules/bim/config/locales/crowdin/pt-BR.yml | 2 +- modules/bim/config/locales/crowdin/pt-PT.seeders.yml | 2 +- modules/bim/config/locales/crowdin/pt-PT.yml | 2 +- modules/boards/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/boards/config/locales/crowdin/js-pt-PT.yml | 2 +- .../boards/config/locales/crowdin/pt-BR.seeders.yml | 2 +- modules/boards/config/locales/crowdin/pt-BR.yml | 2 +- .../boards/config/locales/crowdin/pt-PT.seeders.yml | 2 +- modules/boards/config/locales/crowdin/pt-PT.yml | 2 +- modules/budgets/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/budgets/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/budgets/config/locales/crowdin/pt-BR.yml | 2 +- modules/budgets/config/locales/crowdin/pt-PT.yml | 2 +- modules/calendar/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/calendar/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/calendar/config/locales/crowdin/pt-BR.yml | 2 +- modules/calendar/config/locales/crowdin/pt-PT.yml | 2 +- modules/costs/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/costs/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/costs/config/locales/crowdin/pt-BR.yml | 2 +- modules/costs/config/locales/crowdin/pt-PT.yml | 2 +- modules/dashboards/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/dashboards/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/dashboards/config/locales/crowdin/pt-BR.yml | 2 +- modules/dashboards/config/locales/crowdin/pt-PT.yml | 2 +- modules/documents/config/locales/crowdin/pt-BR.yml | 2 +- modules/documents/config/locales/crowdin/pt-PT.yml | 2 +- modules/gantt/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/gantt/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/gantt/config/locales/crowdin/pt-BR.yml | 2 +- modules/gantt/config/locales/crowdin/pt-PT.yml | 2 +- .../config/locales/crowdin/js-pt-BR.yml | 2 +- .../config/locales/crowdin/js-pt-PT.yml | 2 +- .../config/locales/crowdin/pt-BR.yml | 2 +- .../config/locales/crowdin/pt-PT.yml | 2 +- .../config/locales/crowdin/js-pt-BR.yml | 2 +- .../config/locales/crowdin/js-pt-PT.yml | 2 +- .../config/locales/crowdin/pt-BR.yml | 2 +- .../config/locales/crowdin/pt-PT.yml | 2 +- modules/grids/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/grids/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/grids/config/locales/crowdin/pt-BR.yml | 2 +- modules/grids/config/locales/crowdin/pt-PT.yml | 2 +- modules/job_status/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/job_status/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/job_status/config/locales/crowdin/pt-BR.yml | 2 +- modules/job_status/config/locales/crowdin/pt-PT.yml | 2 +- modules/ldap_groups/config/locales/crowdin/pt-BR.yml | 2 +- modules/ldap_groups/config/locales/crowdin/pt-PT.yml | 2 +- modules/meeting/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/meeting/config/locales/crowdin/js-pt-PT.yml | 2 +- .../meeting/config/locales/crowdin/pt-BR.seeders.yml | 2 +- modules/meeting/config/locales/crowdin/pt-BR.yml | 2 +- .../meeting/config/locales/crowdin/pt-PT.seeders.yml | 2 +- modules/meeting/config/locales/crowdin/pt-PT.yml | 2 +- modules/my_page/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/my_page/config/locales/crowdin/js-pt-PT.yml | 2 +- .../openid_connect/config/locales/crowdin/pt-BR.yml | 2 +- .../openid_connect/config/locales/crowdin/pt-PT.yml | 2 +- modules/overviews/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/overviews/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/overviews/config/locales/crowdin/pt-BR.yml | 2 +- modules/overviews/config/locales/crowdin/pt-PT.yml | 2 +- modules/recaptcha/config/locales/crowdin/pt-BR.yml | 2 +- modules/recaptcha/config/locales/crowdin/pt-PT.yml | 2 +- modules/reporting/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/reporting/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/reporting/config/locales/crowdin/pt-BR.yml | 2 +- modules/reporting/config/locales/crowdin/pt-PT.yml | 2 +- modules/storages/config/locales/crowdin/js-pt-BR.yml | 2 +- modules/storages/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/storages/config/locales/crowdin/pt-BR.yml | 2 +- modules/storages/config/locales/crowdin/pt-PT.yml | 2 +- .../team_planner/config/locales/crowdin/js-pt-BR.yml | 2 +- .../team_planner/config/locales/crowdin/js-pt-PT.yml | 2 +- modules/team_planner/config/locales/crowdin/pt-BR.yml | 2 +- modules/team_planner/config/locales/crowdin/pt-PT.yml | 2 +- .../config/locales/crowdin/js-pt-BR.yml | 2 +- .../config/locales/crowdin/js-pt-PT.yml | 2 +- .../config/locales/crowdin/pt-BR.yml | 2 +- .../config/locales/crowdin/pt-PT.yml | 2 +- modules/webhooks/config/locales/crowdin/pt-BR.yml | 2 +- modules/webhooks/config/locales/crowdin/pt-PT.yml | 2 +- modules/xls_export/config/locales/crowdin/pt-BR.yml | 2 +- modules/xls_export/config/locales/crowdin/pt-PT.yml | 2 +- script/i18n/fix_crowdin_pt_language_root_key | 10 ++++++++++ 104 files changed, 115 insertions(+), 102 deletions(-) create mode 100755 script/i18n/fix_crowdin_pt_language_root_key diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 205c58e518d1..ade7339c7a73 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -78,6 +78,9 @@ jobs: env: OPENPROJECT_CROWDIN_PROJECT: ${{ secrets.OPENPROJECT_CROWDINV2_PROJECT }} OPENPROJECT_CROWDIN_API_KEY: ${{ secrets.OPENPROJECT_CROWDINV2_API_KEY }} + - name: "Fix root key in Portuguese crowdin translation files" + run: | + script/i18n/fix_crowdin_pt_language_root_key - name: "Commit translations" run: | git config user.name "OpenProject Actions CI" diff --git a/config/locales/crowdin/js-pt-BR.yml b/config/locales/crowdin/js-pt-BR.yml index 54544b894355..226c8fbad3c0 100644 --- a/config/locales/crowdin/js-pt-BR.yml +++ b/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: ajax: hide: "Ocultar" diff --git a/config/locales/crowdin/js-pt-PT.yml b/config/locales/crowdin/js-pt-PT.yml index a71080afd879..e3d53bf65607 100644 --- a/config/locales/crowdin/js-pt-PT.yml +++ b/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: ajax: hide: "Ocultar" diff --git a/config/locales/crowdin/pt-BR.seeders.yml b/config/locales/crowdin/pt-BR.seeders.yml index 6bb58afb32fc..70607b6171e3 100644 --- a/config/locales/crowdin/pt-BR.seeders.yml +++ b/config/locales/crowdin/pt-BR.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-BR: seeds: common: colors: diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index 07a74a345516..f86c7349e66d 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: no_results_title_text: Atualmente, não há o que exibir. activities: index: diff --git a/config/locales/crowdin/pt-PT.seeders.yml b/config/locales/crowdin/pt-PT.seeders.yml index 37bb5c241ee6..97e72a7f968c 100644 --- a/config/locales/crowdin/pt-PT.seeders.yml +++ b/config/locales/crowdin/pt-PT.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-PT: seeds: common: colors: diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index e17a2aab9ea0..19612cd37802 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: no_results_title_text: Atualmente, não há nada para exibir. activities: index: diff --git a/modules/avatars/config/locales/crowdin/js-pt-BR.yml b/modules/avatars/config/locales/crowdin/js-pt-BR.yml index caa968380f55..3b29c9deb4af 100644 --- a/modules/avatars/config/locales/crowdin/js-pt-BR.yml +++ b/modules/avatars/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: label_preview: 'Pré-visualizar' button_update: 'Atualizar' diff --git a/modules/avatars/config/locales/crowdin/js-pt-PT.yml b/modules/avatars/config/locales/crowdin/js-pt-PT.yml index ee292ad27c0f..495cfbe40307 100644 --- a/modules/avatars/config/locales/crowdin/js-pt-PT.yml +++ b/modules/avatars/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: label_preview: 'Pré visualizar' button_update: 'Atualizar' diff --git a/modules/avatars/config/locales/crowdin/pt-BR.yml b/modules/avatars/config/locales/crowdin/pt-BR.yml index 1e6746f8d7cd..1dbac87dd408 100644 --- a/modules/avatars/config/locales/crowdin/pt-BR.yml +++ b/modules/avatars/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_avatars: name: "Imagens do perfil" description: >- diff --git a/modules/avatars/config/locales/crowdin/pt-PT.yml b/modules/avatars/config/locales/crowdin/pt-PT.yml index d1d9bcfcad09..f313f033434e 100644 --- a/modules/avatars/config/locales/crowdin/pt-PT.yml +++ b/modules/avatars/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_avatars: name: "Avatares" description: >- diff --git a/modules/backlogs/config/locales/crowdin/js-pt-BR.yml b/modules/backlogs/config/locales/crowdin/js-pt-BR.yml index 003815df6623..7845c9211a02 100644 --- a/modules/backlogs/config/locales/crowdin/js-pt-BR.yml +++ b/modules/backlogs/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: work_packages: properties: diff --git a/modules/backlogs/config/locales/crowdin/js-pt-PT.yml b/modules/backlogs/config/locales/crowdin/js-pt-PT.yml index b75a09cb2553..fb1abc58136d 100644 --- a/modules/backlogs/config/locales/crowdin/js-pt-PT.yml +++ b/modules/backlogs/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: work_packages: properties: diff --git a/modules/backlogs/config/locales/crowdin/pt-BR.yml b/modules/backlogs/config/locales/crowdin/pt-BR.yml index b8c6e3776b33..63172ca2d9e8 100644 --- a/modules/backlogs/config/locales/crowdin/pt-BR.yml +++ b/modules/backlogs/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_backlogs: name: "Backlogs OpenProject" description: "Este módulo acrescenta recursos que permitem que as equipes ágeis trabalhem com o OpenProject em projetos Scrum." diff --git a/modules/backlogs/config/locales/crowdin/pt-PT.yml b/modules/backlogs/config/locales/crowdin/pt-PT.yml index 04cea10164e7..c77ddaacd14b 100644 --- a/modules/backlogs/config/locales/crowdin/pt-PT.yml +++ b/modules/backlogs/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_backlogs: name: "Repositórios OpenProject" description: "Este módulo acrescenta funcionalidades que permitem às equipas Agile trabalhar com o OpenProject em projetos Scrum." diff --git a/modules/bim/config/locales/crowdin/js-pt-BR.yml b/modules/bim/config/locales/crowdin/js-pt-BR.yml index 646cc77181a5..7425b318dc7a 100644 --- a/modules/bim/config/locales/crowdin/js-pt-BR.yml +++ b/modules/bim/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: bcf: label_bcf: 'BCF' diff --git a/modules/bim/config/locales/crowdin/js-pt-PT.yml b/modules/bim/config/locales/crowdin/js-pt-PT.yml index eb8a6792f08f..3c9b05524a14 100644 --- a/modules/bim/config/locales/crowdin/js-pt-PT.yml +++ b/modules/bim/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: bcf: label_bcf: 'BCF' diff --git a/modules/bim/config/locales/crowdin/pt-BR.seeders.yml b/modules/bim/config/locales/crowdin/pt-BR.seeders.yml index 7774cdba654f..0d82162c385c 100644 --- a/modules/bim/config/locales/crowdin/pt-BR.seeders.yml +++ b/modules/bim/config/locales/crowdin/pt-BR.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-BR: seeds: bim: priorities: diff --git a/modules/bim/config/locales/crowdin/pt-BR.yml b/modules/bim/config/locales/crowdin/pt-BR.yml index 8cc38cdd8892..9c8b89a127b9 100644 --- a/modules/bim/config/locales/crowdin/pt-BR.yml +++ b/modules/bim/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_bim: name: "Funcionalidade BIM e BCF do OpenProject" description: "Este plugin do OpenProject introduz a funcionalidade BIM e BCF." diff --git a/modules/bim/config/locales/crowdin/pt-PT.seeders.yml b/modules/bim/config/locales/crowdin/pt-PT.seeders.yml index fa7f58af58a3..9de5a7939034 100644 --- a/modules/bim/config/locales/crowdin/pt-PT.seeders.yml +++ b/modules/bim/config/locales/crowdin/pt-PT.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-PT: seeds: bim: priorities: diff --git a/modules/bim/config/locales/crowdin/pt-PT.yml b/modules/bim/config/locales/crowdin/pt-PT.yml index f24bbb2f7ef6..ce55bd40cda8 100644 --- a/modules/bim/config/locales/crowdin/pt-PT.yml +++ b/modules/bim/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_bim: name: "Funcionalidade BIM e BCF do OpenProject" description: "Este plugin do OpenProject introduz a funcionalidade BIM e BCF." diff --git a/modules/boards/config/locales/crowdin/js-pt-BR.yml b/modules/boards/config/locales/crowdin/js-pt-BR.yml index b316762e5133..aa4e60e5ce6b 100644 --- a/modules/boards/config/locales/crowdin/js-pt-BR.yml +++ b/modules/boards/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: boards: create_new: 'Criar novo quadro' diff --git a/modules/boards/config/locales/crowdin/js-pt-PT.yml b/modules/boards/config/locales/crowdin/js-pt-PT.yml index bc9108070da1..d447f2d2081c 100644 --- a/modules/boards/config/locales/crowdin/js-pt-PT.yml +++ b/modules/boards/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: boards: create_new: 'Criar novo quadro' diff --git a/modules/boards/config/locales/crowdin/pt-BR.seeders.yml b/modules/boards/config/locales/crowdin/pt-BR.seeders.yml index e4bdb243507c..d180fdf67bfc 100644 --- a/modules/boards/config/locales/crowdin/pt-BR.seeders.yml +++ b/modules/boards/config/locales/crowdin/pt-BR.seeders.yml @@ -5,4 +5,4 @@ #located in the modules directories are needed to have crowdin cli correctly #compute the path to the uploaded source file. #This file does not contain any i18n strings. -pt: +pt-BR: diff --git a/modules/boards/config/locales/crowdin/pt-BR.yml b/modules/boards/config/locales/crowdin/pt-BR.yml index 487980ca1315..0149cc4ef9ec 100644 --- a/modules/boards/config/locales/crowdin/pt-BR.yml +++ b/modules/boards/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_boards: name: "Quadros do OpenProject" description: "Fornece visualizações do quadro." diff --git a/modules/boards/config/locales/crowdin/pt-PT.seeders.yml b/modules/boards/config/locales/crowdin/pt-PT.seeders.yml index e4bdb243507c..bdd962ed457a 100644 --- a/modules/boards/config/locales/crowdin/pt-PT.seeders.yml +++ b/modules/boards/config/locales/crowdin/pt-PT.seeders.yml @@ -5,4 +5,4 @@ #located in the modules directories are needed to have crowdin cli correctly #compute the path to the uploaded source file. #This file does not contain any i18n strings. -pt: +pt-PT: diff --git a/modules/boards/config/locales/crowdin/pt-PT.yml b/modules/boards/config/locales/crowdin/pt-PT.yml index 5bfefefb0647..45805106c57c 100644 --- a/modules/boards/config/locales/crowdin/pt-PT.yml +++ b/modules/boards/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_boards: name: "Quadros do OpenProject" description: "Fornece vistas do quadro." diff --git a/modules/budgets/config/locales/crowdin/js-pt-BR.yml b/modules/budgets/config/locales/crowdin/js-pt-BR.yml index b61d8f714f35..d821c6ce69c5 100644 --- a/modules/budgets/config/locales/crowdin/js-pt-BR.yml +++ b/modules/budgets/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: work_packages: properties: diff --git a/modules/budgets/config/locales/crowdin/js-pt-PT.yml b/modules/budgets/config/locales/crowdin/js-pt-PT.yml index b61d8f714f35..c5a75d8a6e1e 100644 --- a/modules/budgets/config/locales/crowdin/js-pt-PT.yml +++ b/modules/budgets/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: work_packages: properties: diff --git a/modules/budgets/config/locales/crowdin/pt-BR.yml b/modules/budgets/config/locales/crowdin/pt-BR.yml index 2ff55be55ffb..6dac6a7faae0 100644 --- a/modules/budgets/config/locales/crowdin/pt-BR.yml +++ b/modules/budgets/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_budgets_engine: name: 'Orçamentos' activerecord: diff --git a/modules/budgets/config/locales/crowdin/pt-PT.yml b/modules/budgets/config/locales/crowdin/pt-PT.yml index 90e2d6642b4e..1b4ced7bc577 100644 --- a/modules/budgets/config/locales/crowdin/pt-PT.yml +++ b/modules/budgets/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_budgets_engine: name: 'Orçamentos' activerecord: diff --git a/modules/calendar/config/locales/crowdin/js-pt-BR.yml b/modules/calendar/config/locales/crowdin/js-pt-BR.yml index 5b6274d63a34..ad51db24e136 100644 --- a/modules/calendar/config/locales/crowdin/js-pt-BR.yml +++ b/modules/calendar/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: calendar: create_new: 'Criar novo calendário' diff --git a/modules/calendar/config/locales/crowdin/js-pt-PT.yml b/modules/calendar/config/locales/crowdin/js-pt-PT.yml index a6e21759e651..84b80223c2fa 100644 --- a/modules/calendar/config/locales/crowdin/js-pt-PT.yml +++ b/modules/calendar/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: calendar: create_new: 'Criar novo calendário' diff --git a/modules/calendar/config/locales/crowdin/pt-BR.yml b/modules/calendar/config/locales/crowdin/pt-BR.yml index ffc1a019908a..e434cc6516a4 100644 --- a/modules/calendar/config/locales/crowdin/pt-BR.yml +++ b/modules/calendar/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_calendar: name: "Calendário OpenProject" description: "Fornece visualizações do calendário." diff --git a/modules/calendar/config/locales/crowdin/pt-PT.yml b/modules/calendar/config/locales/crowdin/pt-PT.yml index 8806bbabe1cf..2ce4bfac061f 100644 --- a/modules/calendar/config/locales/crowdin/pt-PT.yml +++ b/modules/calendar/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_calendar: name: "Calendário OpenProject" description: "Fornece visualizações do calendário." diff --git a/modules/costs/config/locales/crowdin/js-pt-BR.yml b/modules/costs/config/locales/crowdin/js-pt-BR.yml index 546b2d01fba3..b082a46b6d16 100644 --- a/modules/costs/config/locales/crowdin/js-pt-BR.yml +++ b/modules/costs/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: work_packages: property_groups: diff --git a/modules/costs/config/locales/crowdin/js-pt-PT.yml b/modules/costs/config/locales/crowdin/js-pt-PT.yml index 3294144067d6..8498784808e7 100644 --- a/modules/costs/config/locales/crowdin/js-pt-PT.yml +++ b/modules/costs/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: work_packages: property_groups: diff --git a/modules/costs/config/locales/crowdin/pt-BR.yml b/modules/costs/config/locales/crowdin/pt-BR.yml index fe5298744da6..b4ec08e3065f 100644 --- a/modules/costs/config/locales/crowdin/pt-BR.yml +++ b/modules/costs/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_costs: name: "Tempo e custos" description: "Este módulo acrescenta recursos para planejar e monitorar os custos dos projetos." diff --git a/modules/costs/config/locales/crowdin/pt-PT.yml b/modules/costs/config/locales/crowdin/pt-PT.yml index c4d939811e42..e3e3eb6fa74f 100644 --- a/modules/costs/config/locales/crowdin/pt-PT.yml +++ b/modules/costs/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_costs: name: "Tempo e custos" description: "Este módulo acrescenta funcionalidades para planear e acompanhar os custos dos projetos." diff --git a/modules/dashboards/config/locales/crowdin/js-pt-BR.yml b/modules/dashboards/config/locales/crowdin/js-pt-BR.yml index 4ab0b6b573fe..9aafff8e6417 100644 --- a/modules/dashboards/config/locales/crowdin/js-pt-BR.yml +++ b/modules/dashboards/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: dashboards: label: 'Painel' diff --git a/modules/dashboards/config/locales/crowdin/js-pt-PT.yml b/modules/dashboards/config/locales/crowdin/js-pt-PT.yml index f390c59ac102..862ac47440ef 100644 --- a/modules/dashboards/config/locales/crowdin/js-pt-PT.yml +++ b/modules/dashboards/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: dashboards: label: 'Painel de Controlo' diff --git a/modules/dashboards/config/locales/crowdin/pt-BR.yml b/modules/dashboards/config/locales/crowdin/pt-BR.yml index 21ba5bc596c0..d55391e6ca30 100644 --- a/modules/dashboards/config/locales/crowdin/pt-BR.yml +++ b/modules/dashboards/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: dashboards: label: 'Painéis' project_module_dashboards: 'Painéis' diff --git a/modules/dashboards/config/locales/crowdin/pt-PT.yml b/modules/dashboards/config/locales/crowdin/pt-PT.yml index 6f6e5d785e05..e884662919c2 100644 --- a/modules/dashboards/config/locales/crowdin/pt-PT.yml +++ b/modules/dashboards/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: dashboards: label: 'Painéis de controlo' project_module_dashboards: 'Painéis de controle' diff --git a/modules/documents/config/locales/crowdin/pt-BR.yml b/modules/documents/config/locales/crowdin/pt-BR.yml index e702bd7f9a7d..d3f4f66d2c16 100644 --- a/modules/documents/config/locales/crowdin/pt-BR.yml +++ b/modules/documents/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_documents: name: "Documentos do OpenProject" description: "Um plugin OpenProject para permitir a criação de documentos em projetos." diff --git a/modules/documents/config/locales/crowdin/pt-PT.yml b/modules/documents/config/locales/crowdin/pt-PT.yml index 964ed01ea63a..22cb9da2f10e 100644 --- a/modules/documents/config/locales/crowdin/pt-PT.yml +++ b/modules/documents/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_documents: name: "Documentos do OpenProject" description: "Um plugin OpenProject para permitir a criação de documentos em projetos." diff --git a/modules/gantt/config/locales/crowdin/js-pt-BR.yml b/modules/gantt/config/locales/crowdin/js-pt-BR.yml index 0040575842b8..a4802317bbb2 100644 --- a/modules/gantt/config/locales/crowdin/js-pt-BR.yml +++ b/modules/gantt/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: queries: all_open: 'Tudo aberto' diff --git a/modules/gantt/config/locales/crowdin/js-pt-PT.yml b/modules/gantt/config/locales/crowdin/js-pt-PT.yml index d477cf988cc4..bfd6b4a1e0c4 100644 --- a/modules/gantt/config/locales/crowdin/js-pt-PT.yml +++ b/modules/gantt/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: queries: all_open: 'Tudo aberto' diff --git a/modules/gantt/config/locales/crowdin/pt-BR.yml b/modules/gantt/config/locales/crowdin/pt-BR.yml index 30806f322d02..4b2fa1b786dc 100644 --- a/modules/gantt/config/locales/crowdin/pt-BR.yml +++ b/modules/gantt/config/locales/crowdin/pt-BR.yml @@ -1,3 +1,3 @@ #English strings go here -pt: +pt-BR: project_module_gantt: "Gráficos de Gantt" diff --git a/modules/gantt/config/locales/crowdin/pt-PT.yml b/modules/gantt/config/locales/crowdin/pt-PT.yml index 30806f322d02..73fb705fcc5a 100644 --- a/modules/gantt/config/locales/crowdin/pt-PT.yml +++ b/modules/gantt/config/locales/crowdin/pt-PT.yml @@ -1,3 +1,3 @@ #English strings go here -pt: +pt-PT: project_module_gantt: "Gráficos de Gantt" diff --git a/modules/github_integration/config/locales/crowdin/js-pt-BR.yml b/modules/github_integration/config/locales/crowdin/js-pt-BR.yml index cc5634c62aaa..07bc9f351d5f 100644 --- a/modules/github_integration/config/locales/crowdin/js-pt-BR.yml +++ b/modules/github_integration/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: github_integration: work_packages: diff --git a/modules/github_integration/config/locales/crowdin/js-pt-PT.yml b/modules/github_integration/config/locales/crowdin/js-pt-PT.yml index e20fbd308026..c9bf44aa52a1 100644 --- a/modules/github_integration/config/locales/crowdin/js-pt-PT.yml +++ b/modules/github_integration/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: github_integration: work_packages: diff --git a/modules/github_integration/config/locales/crowdin/pt-BR.yml b/modules/github_integration/config/locales/crowdin/pt-BR.yml index f793bb60845f..a623297c3791 100644 --- a/modules/github_integration/config/locales/crowdin/pt-BR.yml +++ b/modules/github_integration/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_github_integration: name: "Integração do OpenProject GitHub" description: "Integra o OpenProject e o GitHub para um melhor fluxo de trabalho" diff --git a/modules/github_integration/config/locales/crowdin/pt-PT.yml b/modules/github_integration/config/locales/crowdin/pt-PT.yml index d1e32ea7bc86..319a17c6e6a0 100644 --- a/modules/github_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/github_integration/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_github_integration: name: "Integração do OpenProject GitHub" description: "Integra o OpenProject e o GitHub para um melhor fluxo de trabalho" diff --git a/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml b/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml index 2f95b55b1fd1..4c30f98f039c 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-BR: js: gitlab_integration: work_packages: diff --git a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml index 028bbc84e0a9..f01e53cdbfc6 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-PT: js: gitlab_integration: work_packages: diff --git a/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml b/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml index 9178e4b1df36..992338e9171a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml +++ b/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-BR: activerecord: errors: models: diff --git a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml index 548286d56923..2281be87d617 100644 --- a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-PT: activerecord: errors: models: diff --git a/modules/grids/config/locales/crowdin/js-pt-BR.yml b/modules/grids/config/locales/crowdin/js-pt-BR.yml index 589f704ef934..c39352b80807 100644 --- a/modules/grids/config/locales/crowdin/js-pt-BR.yml +++ b/modules/grids/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: grid: add_widget: 'Adicionar widget' diff --git a/modules/grids/config/locales/crowdin/js-pt-PT.yml b/modules/grids/config/locales/crowdin/js-pt-PT.yml index 924418f14a34..468f88f1cc3f 100644 --- a/modules/grids/config/locales/crowdin/js-pt-PT.yml +++ b/modules/grids/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: grid: add_widget: 'Adicionar widget' diff --git a/modules/grids/config/locales/crowdin/pt-BR.yml b/modules/grids/config/locales/crowdin/pt-BR.yml index 1f8442bd164d..94418b933836 100644 --- a/modules/grids/config/locales/crowdin/pt-BR.yml +++ b/modules/grids/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: grids: label_widget_in_grid: "Widget contido na Grade %{grid_name}" activerecord: diff --git a/modules/grids/config/locales/crowdin/pt-PT.yml b/modules/grids/config/locales/crowdin/pt-PT.yml index 34c68b02bce0..dbc6415e633a 100644 --- a/modules/grids/config/locales/crowdin/pt-PT.yml +++ b/modules/grids/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: grids: label_widget_in_grid: "Widget contido na Grelha %{grid_name}" activerecord: diff --git a/modules/job_status/config/locales/crowdin/js-pt-BR.yml b/modules/job_status/config/locales/crowdin/js-pt-BR.yml index fb2cf29d3368..f69fcd5a0e39 100644 --- a/modules/job_status/config/locales/crowdin/js-pt-BR.yml +++ b/modules/job_status/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: job_status: download_starts: 'O download deve iniciar automaticamente.' diff --git a/modules/job_status/config/locales/crowdin/js-pt-PT.yml b/modules/job_status/config/locales/crowdin/js-pt-PT.yml index ac1e013d8119..1dbc20237719 100644 --- a/modules/job_status/config/locales/crowdin/js-pt-PT.yml +++ b/modules/job_status/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: job_status: download_starts: 'O download deve iniciar automaticamente.' diff --git a/modules/job_status/config/locales/crowdin/pt-BR.yml b/modules/job_status/config/locales/crowdin/pt-BR.yml index ec08d1fbbb60..45c91159ffe6 100644 --- a/modules/job_status/config/locales/crowdin/pt-BR.yml +++ b/modules/job_status/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_job_status: name: "Situação do trabalho OpenProject" description: "Listagem e situação dos trabalhos em segundo plano." diff --git a/modules/job_status/config/locales/crowdin/pt-PT.yml b/modules/job_status/config/locales/crowdin/pt-PT.yml index 01b7b950b87b..bec78ecb81c7 100644 --- a/modules/job_status/config/locales/crowdin/pt-PT.yml +++ b/modules/job_status/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_job_status: name: "Estado do trabalho OpenProject" description: "Listagem e estado dos trabalhos em segundo plano." diff --git a/modules/ldap_groups/config/locales/crowdin/pt-BR.yml b/modules/ldap_groups/config/locales/crowdin/pt-BR.yml index 520680581282..5674ce36a1e4 100644 --- a/modules/ldap_groups/config/locales/crowdin/pt-BR.yml +++ b/modules/ldap_groups/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_ldap_groups: name: "Grupos LDAP do OpenProject" description: "Sincronização de associações de grupos LDAP." diff --git a/modules/ldap_groups/config/locales/crowdin/pt-PT.yml b/modules/ldap_groups/config/locales/crowdin/pt-PT.yml index e78e0ac38495..3bf9b12f09ed 100644 --- a/modules/ldap_groups/config/locales/crowdin/pt-PT.yml +++ b/modules/ldap_groups/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_ldap_groups: name: "Grupos LDAP do OpenProject" description: "Sincronização de associações de grupos LDAP." diff --git a/modules/meeting/config/locales/crowdin/js-pt-BR.yml b/modules/meeting/config/locales/crowdin/js-pt-BR.yml index 5f850f4538e2..47c62b8943fd 100644 --- a/modules/meeting/config/locales/crowdin/js-pt-BR.yml +++ b/modules/meeting/config/locales/crowdin/js-pt-BR.yml @@ -19,6 +19,6 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: label_meetings: 'Reuniões' diff --git a/modules/meeting/config/locales/crowdin/js-pt-PT.yml b/modules/meeting/config/locales/crowdin/js-pt-PT.yml index 5f850f4538e2..16cdac936349 100644 --- a/modules/meeting/config/locales/crowdin/js-pt-PT.yml +++ b/modules/meeting/config/locales/crowdin/js-pt-PT.yml @@ -19,6 +19,6 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: label_meetings: 'Reuniões' diff --git a/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml b/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml index 8049e00f3b77..2d1eb4c6d26e 100644 --- a/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml +++ b/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-BR: seeds: standard: projects: diff --git a/modules/meeting/config/locales/crowdin/pt-BR.yml b/modules/meeting/config/locales/crowdin/pt-BR.yml index 5e6db0db004f..c20cde6f42ac 100644 --- a/modules/meeting/config/locales/crowdin/pt-BR.yml +++ b/modules/meeting/config/locales/crowdin/pt-BR.yml @@ -20,7 +20,7 @@ #See COPYRIGHT and LICENSE files for more details. #++ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_meeting: name: "Reunião do OpenProject" description: >- diff --git a/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml b/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml index 80e5be5ebe2f..4f63928bdae6 100644 --- a/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml +++ b/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-PT: seeds: standard: projects: diff --git a/modules/meeting/config/locales/crowdin/pt-PT.yml b/modules/meeting/config/locales/crowdin/pt-PT.yml index 87e1555225bf..5431a9b95d5e 100644 --- a/modules/meeting/config/locales/crowdin/pt-PT.yml +++ b/modules/meeting/config/locales/crowdin/pt-PT.yml @@ -20,7 +20,7 @@ #See COPYRIGHT and LICENSE files for more details. #++ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_meeting: name: "Reunião do OpenProject" description: >- diff --git a/modules/my_page/config/locales/crowdin/js-pt-BR.yml b/modules/my_page/config/locales/crowdin/js-pt-BR.yml index 859e96f039bf..03810b223f34 100644 --- a/modules/my_page/config/locales/crowdin/js-pt-BR.yml +++ b/modules/my_page/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: my_page: label: "Minha página" diff --git a/modules/my_page/config/locales/crowdin/js-pt-PT.yml b/modules/my_page/config/locales/crowdin/js-pt-PT.yml index d9dc89e0571b..787cc615dbb6 100644 --- a/modules/my_page/config/locales/crowdin/js-pt-PT.yml +++ b/modules/my_page/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: my_page: label: "A minha página" diff --git a/modules/openid_connect/config/locales/crowdin/pt-BR.yml b/modules/openid_connect/config/locales/crowdin/pt-BR.yml index 41d80a844de5..74764dad5681 100644 --- a/modules/openid_connect/config/locales/crowdin/pt-BR.yml +++ b/modules/openid_connect/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_openid_connect: name: "Conectar OpenProject OpenID" description: "Adiciona provedores de estratégia OmniAuth OpenID Connect ao Openproject." diff --git a/modules/openid_connect/config/locales/crowdin/pt-PT.yml b/modules/openid_connect/config/locales/crowdin/pt-PT.yml index e64f1d0d91f2..8ac260196e32 100644 --- a/modules/openid_connect/config/locales/crowdin/pt-PT.yml +++ b/modules/openid_connect/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_openid_connect: name: "OpenProject OpenID Connect" description: "Adiciona fornecedores da estratégia OmniAuth OpenID Connect ao Openproject." diff --git a/modules/overviews/config/locales/crowdin/js-pt-BR.yml b/modules/overviews/config/locales/crowdin/js-pt-BR.yml index 436bc25f1a36..7f83bd2a2caa 100644 --- a/modules/overviews/config/locales/crowdin/js-pt-BR.yml +++ b/modules/overviews/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: overviews: label: 'Visão geral' diff --git a/modules/overviews/config/locales/crowdin/js-pt-PT.yml b/modules/overviews/config/locales/crowdin/js-pt-PT.yml index 0b2501692c3f..bf7cabe59466 100644 --- a/modules/overviews/config/locales/crowdin/js-pt-PT.yml +++ b/modules/overviews/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: overviews: label: 'Sinopse' diff --git a/modules/overviews/config/locales/crowdin/pt-BR.yml b/modules/overviews/config/locales/crowdin/pt-BR.yml index d52a98c9f0e4..62a5e4574b57 100644 --- a/modules/overviews/config/locales/crowdin/pt-BR.yml +++ b/modules/overviews/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: overviews: label: 'Visão geral' permission_manage_overview: 'Gerenciar página de visão geral' diff --git a/modules/overviews/config/locales/crowdin/pt-PT.yml b/modules/overviews/config/locales/crowdin/pt-PT.yml index bb1318a226f0..d25abafa09e9 100644 --- a/modules/overviews/config/locales/crowdin/pt-PT.yml +++ b/modules/overviews/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: overviews: label: 'Visão geral' permission_manage_overview: 'Gerir página de visão geral' diff --git a/modules/recaptcha/config/locales/crowdin/pt-BR.yml b/modules/recaptcha/config/locales/crowdin/pt-BR.yml index 83fcf63093cb..16dffd5b6778 100644 --- a/modules/recaptcha/config/locales/crowdin/pt-BR.yml +++ b/modules/recaptcha/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_recaptcha: name: "ReCaptcha do OpenProject" description: "Este módulo fornece verificações recaptcha durante o início de sessão." diff --git a/modules/recaptcha/config/locales/crowdin/pt-PT.yml b/modules/recaptcha/config/locales/crowdin/pt-PT.yml index 9344c9bdf49e..7982b12d7a42 100644 --- a/modules/recaptcha/config/locales/crowdin/pt-PT.yml +++ b/modules/recaptcha/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_recaptcha: name: "ReCaptcha do OpenProject" description: "Este módulo fornece verificações recaptcha durante o início de sessão." diff --git a/modules/reporting/config/locales/crowdin/js-pt-BR.yml b/modules/reporting/config/locales/crowdin/js-pt-BR.yml index 30c0350863d9..c94fd8e3d4f0 100644 --- a/modules/reporting/config/locales/crowdin/js-pt-BR.yml +++ b/modules/reporting/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: reporting_engine: label_remove: "Excluir" diff --git a/modules/reporting/config/locales/crowdin/js-pt-PT.yml b/modules/reporting/config/locales/crowdin/js-pt-PT.yml index 58373c5bb89b..dbe5aa4f6c33 100644 --- a/modules/reporting/config/locales/crowdin/js-pt-PT.yml +++ b/modules/reporting/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: reporting_engine: label_remove: "Eliminar" diff --git a/modules/reporting/config/locales/crowdin/pt-BR.yml b/modules/reporting/config/locales/crowdin/pt-BR.yml index 5f77c6611e28..fe16440fb12c 100644 --- a/modules/reporting/config/locales/crowdin/pt-BR.yml +++ b/modules/reporting/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_reporting: name: "Relatórios do OpenProject" description: "Este plugin permite a criação de relatórios de custos personalizados com filtragem e agrupamento criados pelo plugin OpenProject Time e custos." diff --git a/modules/reporting/config/locales/crowdin/pt-PT.yml b/modules/reporting/config/locales/crowdin/pt-PT.yml index d5d5b525186c..d16aea0d54ef 100644 --- a/modules/reporting/config/locales/crowdin/pt-PT.yml +++ b/modules/reporting/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_reporting: name: "Relatórios do OpenProject" description: "Este plugin permite criar relatórios de custos personalizados com filtragem e agrupamento criados pelo plugin OpenProject Time e custos." diff --git a/modules/storages/config/locales/crowdin/js-pt-BR.yml b/modules/storages/config/locales/crowdin/js-pt-BR.yml index 9a28a2b6299d..3b18fc043427 100644 --- a/modules/storages/config/locales/crowdin/js-pt-BR.yml +++ b/modules/storages/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: storages: link_files_in_storage: "Vincular arquivos em %{storageType}" diff --git a/modules/storages/config/locales/crowdin/js-pt-PT.yml b/modules/storages/config/locales/crowdin/js-pt-PT.yml index ca2915605e49..3afdd9bc6d84 100644 --- a/modules/storages/config/locales/crowdin/js-pt-PT.yml +++ b/modules/storages/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: storages: link_files_in_storage: "Vincular ficheiros em %{storageType}" diff --git a/modules/storages/config/locales/crowdin/pt-BR.yml b/modules/storages/config/locales/crowdin/pt-BR.yml index a9758828e8ae..04de4e7e4ae8 100644 --- a/modules/storages/config/locales/crowdin/pt-BR.yml +++ b/modules/storages/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: activerecord: attributes: storages/file_link: diff --git a/modules/storages/config/locales/crowdin/pt-PT.yml b/modules/storages/config/locales/crowdin/pt-PT.yml index 585507858b36..791b6d5cb368 100644 --- a/modules/storages/config/locales/crowdin/pt-PT.yml +++ b/modules/storages/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: activerecord: attributes: storages/file_link: diff --git a/modules/team_planner/config/locales/crowdin/js-pt-BR.yml b/modules/team_planner/config/locales/crowdin/js-pt-BR.yml index 4b78496fcd9c..1a95e571f2a6 100644 --- a/modules/team_planner/config/locales/crowdin/js-pt-BR.yml +++ b/modules/team_planner/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: team_planner: add_existing: 'Adicionar existente' diff --git a/modules/team_planner/config/locales/crowdin/js-pt-PT.yml b/modules/team_planner/config/locales/crowdin/js-pt-PT.yml index 560997823d7a..a2e055780178 100644 --- a/modules/team_planner/config/locales/crowdin/js-pt-PT.yml +++ b/modules/team_planner/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: team_planner: add_existing: 'Adicionar existente' diff --git a/modules/team_planner/config/locales/crowdin/pt-BR.yml b/modules/team_planner/config/locales/crowdin/pt-BR.yml index c6f1146ba653..7206c4ba8dfe 100644 --- a/modules/team_planner/config/locales/crowdin/pt-BR.yml +++ b/modules/team_planner/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_team_planner: name: "Planejador de equipes OpenProject" description: "Fornece visualizações do planejador de equipes." diff --git a/modules/team_planner/config/locales/crowdin/pt-PT.yml b/modules/team_planner/config/locales/crowdin/pt-PT.yml index e1a02a84bd72..4d1150b1f110 100644 --- a/modules/team_planner/config/locales/crowdin/pt-PT.yml +++ b/modules/team_planner/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_team_planner: name: "Planeador de equipas OpenProject" description: "Fornece visualizações do planeador de equipas." diff --git a/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml b/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml index 7d4f33fa04af..372544c095be 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: two_factor_authentication: errors: diff --git a/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml b/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml index dbaec2f1af3a..d51a43480f8f 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: two_factor_authentication: errors: diff --git a/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml b/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml index c3f99ecebd79..684c982be7ec 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_two_factor_authentication: name: "Autenticação de dois fatores do OpenProject" description: >- diff --git a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml index ba45743d6f08..713fd96f3f0b 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_two_factor_authentication: name: "Autenticação de dois fatores do OpenProject" description: >- diff --git a/modules/webhooks/config/locales/crowdin/pt-BR.yml b/modules/webhooks/config/locales/crowdin/pt-BR.yml index 71622189a0f3..0a3c5cba4673 100644 --- a/modules/webhooks/config/locales/crowdin/pt-BR.yml +++ b/modules/webhooks/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_webhooks: name: "Webhooks do OpenProject" description: "Fornece uma API de plug-in para dar suporte aos webhooks do OpenProject para uma melhor integração de terceiros." diff --git a/modules/webhooks/config/locales/crowdin/pt-PT.yml b/modules/webhooks/config/locales/crowdin/pt-PT.yml index 8bdf94e64dc9..1496aff7e003 100644 --- a/modules/webhooks/config/locales/crowdin/pt-PT.yml +++ b/modules/webhooks/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_webhooks: name: "Webhooks do OpenProject" description: "Fornece uma API de plug-in para suportar webhooks do OpenProject para uma melhor integração de terceiros." diff --git a/modules/xls_export/config/locales/crowdin/pt-BR.yml b/modules/xls_export/config/locales/crowdin/pt-BR.yml index c024d4d7edb0..31bcd6d4053b 100644 --- a/modules/xls_export/config/locales/crowdin/pt-BR.yml +++ b/modules/xls_export/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_xls_export: name: "Exportação XLS do OpenProject" description: "Exportar listas de problemas como planilhas Excel (.xls)." diff --git a/modules/xls_export/config/locales/crowdin/pt-PT.yml b/modules/xls_export/config/locales/crowdin/pt-PT.yml index b2691b6ba103..440931f9de6d 100644 --- a/modules/xls_export/config/locales/crowdin/pt-PT.yml +++ b/modules/xls_export/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_xls_export: name: "Exportação XLS do OpenProject" description: "Exportar listas de problemas como folhas de cálculo do Excel (.xls)." diff --git a/script/i18n/fix_crowdin_pt_language_root_key b/script/i18n/fix_crowdin_pt_language_root_key new file mode 100755 index 000000000000..0237262f1427 --- /dev/null +++ b/script/i18n/fix_crowdin_pt_language_root_key @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +echo "Fixing language root key in pt-BR and pt-PT crowdin files to match the filename" +if [ "$(uname -s)" = "Darwin" ]; then + sed -i '' 's/^pt:/pt-BR:/' config/locales/crowdin/*pt-BR*.yml modules/*/config/locales/crowdin/*pt-BR*.yml + sed -i '' 's/^pt:/pt-PT:/' config/locales/crowdin/*pt-PT*.yml modules/*/config/locales/crowdin/*pt-PT*.yml +else + sed -i 's/^pt:/pt-BR:/' config/locales/crowdin/*pt-BR*.yml modules/*/config/locales/crowdin/*pt-BR*.yml + sed -i 's/^pt:/pt-PT:/' config/locales/crowdin/*pt-PT*.yml modules/*/config/locales/crowdin/*pt-PT*.yml +fi From 1b44a61ccc20aa94dc9c442aee1aff06db65e61e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Mar 2024 19:51:43 +0000 Subject: [PATCH 194/218] build(deps): bump webpack-dev-middleware in /frontend Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4. - [Release notes](https://github.com/webpack/webpack-dev-middleware/releases) - [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4) --- updated-dependencies: - dependency-name: webpack-dev-middleware dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 916c75b717ae..fd78e766c3a9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20474,9 +20474,9 @@ } }, "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", @@ -35863,9 +35863,9 @@ } }, "webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "requires": { "colorette": "^2.0.10", "memfs": "^3.4.3", From 00fc9df110cbde7591d89ffbaa38bfe3d9ad8bbd Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Fri, 22 Mar 2024 03:05:44 +0000 Subject: [PATCH 195/218] update locales from crowdin [ci skip] --- config/locales/crowdin/cs.yml | 12 +- config/locales/crowdin/js-pt-PT.yml | 2 +- config/locales/crowdin/lt.yml | 6 +- config/locales/crowdin/pt-BR.yml | 4 +- config/locales/crowdin/pt-PT.yml | 46 +-- config/locales/crowdin/zh-TW.yml | 2 +- .../avatars/config/locales/crowdin/js-hu.yml | 2 +- .../backlogs/config/locales/crowdin/hu.yml | 12 +- .../backlogs/config/locales/crowdin/ms.yml | 2 +- .../bim/config/locales/crowdin/cs.seeders.yml | 4 +- modules/bim/config/locales/crowdin/js-ms.yml | 24 +- .../bim/config/locales/crowdin/ms.seeders.yml | 54 ++-- modules/bim/config/locales/crowdin/ms.yml | 16 +- .../boards/config/locales/crowdin/js-ms.yml | 10 +- modules/boards/config/locales/crowdin/ms.yml | 24 +- .../budgets/config/locales/crowdin/js-ms.yml | 2 +- modules/budgets/config/locales/crowdin/ms.yml | 34 +-- .../calendar/config/locales/crowdin/hu.yml | 8 +- .../calendar/config/locales/crowdin/js-hu.yml | 2 +- modules/costs/config/locales/crowdin/hu.yml | 4 +- .../config/locales/crowdin/cs.yml | 2 +- .../config/locales/crowdin/js-pt-PT.yml | 10 +- .../config/locales/crowdin/pt-PT.yml | 32 +-- .../grids/config/locales/crowdin/js-pt-PT.yml | 2 +- .../ldap_groups/config/locales/crowdin/ms.yml | 4 +- .../meeting/config/locales/crowdin/js-ms.yml | 2 +- .../config/locales/crowdin/ms.seeders.yml | 18 +- modules/meeting/config/locales/crowdin/ms.yml | 268 +++++++++--------- .../my_page/config/locales/crowdin/js-ms.yml | 2 +- .../config/locales/crowdin/ms.yml | 10 +- .../storages/config/locales/crowdin/cs.yml | 2 +- .../storages/config/locales/crowdin/pt-PT.yml | 8 +- .../config/locales/crowdin/js-pt-PT.yml | 4 +- .../config/locales/crowdin/cs.yml | 4 +- .../config/locales/crowdin/pt-PT.yml | 10 +- 35 files changed, 324 insertions(+), 324 deletions(-) diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index 3477811b391f..e237a99b252e 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -1594,7 +1594,7 @@ cs: journals: changes_retracted: "Změny byly staženy." caused_changes: - default_attribute_written: "Read-only attributes written" + default_attribute_written: "Zápis atributů pouze pro čtení" dates_changed: "Data změněna" system_update: "Aktualizace systému OpenProject:" cause_descriptions: @@ -2776,9 +2776,9 @@ cs: setting_apiv3_cors_origins: "API V3 Cross-Origin Resource Sharing (CORS) povolené počátky" setting_apiv3_cors_origins_text_html: > Pokud je funkce CORS povolena, je to původ, který má přístup k OpenProject API.
Zkontrolujte Dokumentaci na záhlaví Origin o tom, jak specifikovat očekávané hodnoty. - setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" + setting_apiv3_write_readonly_attributes: "Přístup k atributům pouze pro čtení" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Pokud je tato možnost povolena, rozhraní API umožní správcům zapisovat při vytváření statické atributy pouze pro čtení, například časová razítka createdAt a updatedAt.
Upozornění: Toto nastavení má využití např. při importu dat, ale umožňuje správcům vydávat se při vytváření položek za jiné uživatele. Všechny požadavky na vytvoření se však zaznamenávají se skutečným autorem.
Další informace o atributech a podporovaných zdrojích naleznete na adrese %{api_documentation_link}. setting_apiv3_max_page_size: "Maximální velikost stránky API" setting_apiv3_max_page_instructions_html: > Nastavte maximální velikost stránky, se kterou bude API reagovat. Nebude možné provádět API požadavky, které vracejí více hodnot na jedné stránce.
Varování: Změňte tuto hodnotu pouze pokud jste si jisti, proč ji potřebujete. Nastavení na vysokou hodnotu bude mít výrazný dopad na výkon, zatímco hodnota nižší, než jsou možnosti na stránce, způsobí chyby v stránkovaných zobrazeních. @@ -2922,7 +2922,7 @@ cs: clamav_socket_html: Zadejte soket démona clamd, např. %{example}. clamav_host_html: Zadejte název hostitele a port démona clamd oddělený dvojtečkou. např. %{example} description_html: > - Select the mode in which the antivirus scanner integration should operate.
  • %{disabled_option}: Uploaded files are not scanned for viruses.
  • %{socket_option}: You have set up ClamAV on the same server as OpenProject and the scan daemon clamd is running in the background
  • %{host_option}: You are streaming files to an external virus scanning host.
+ Vyberte režim, ve kterém by měla fungovat integrace antivirového scanneru.
  • %{disabled_option}: Nahrané soubory nejsou naskenovány pro viry.
  • %{socket_option}: Nastavili jste ClamAV na stejném serveru jako OpenProject a scan daemon clamd běží na pozadí
  • %{host_option}: Vysíláte soubory do externího hostitele pro skenování virů.
brute_force_prevention: "Automatizované blokování uživatelů" date_format: first_date_of_week_and_year_set: > @@ -3159,7 +3159,7 @@ cs: status_user_and_brute_force: "%{user} a %{brute_force}" status_change: "Změna stavu" text_change_disabled_for_provider_login: "Jméno je nastaveno vaším administrátorem přihlášení, a proto jej nelze změnit." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "Jméno a e-mail jsou nastaveny pomocí LDAP, a nelze je tedy měnit." unlock: "Odemknout" unlock_and_reset_failed_logins: "Odemknout a resetovat nezdařené přihlášení" version_status_closed: "uzavřeno" @@ -3233,7 +3233,7 @@ cs: text_empty_search_header: "Nenašli jsme žádné odpovídající výsledky." text_empty_state_description: "Pracovní balíček zatím nebyl s nikým sdílen." text_empty_state_header: "Není sdíleno" - text_user_limit_reached: "Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package." + text_user_limit_reached: "Přidáním dalších uživatelů bude aktuální limit překročen. Pro zvýšení limitu uživatelů kontaktujte správce, abyste zajistili přístup externích uživatelů k tomuto pracovnímu balíčku." text_user_limit_reached_admins: 'Adding additional users will exceed the current limit. Please upgrade your plan to be able to add more users.' warning_user_limit_reached: > Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package. diff --git a/config/locales/crowdin/js-pt-PT.yml b/config/locales/crowdin/js-pt-PT.yml index e3d53bf65607..d6ad4659f73a 100644 --- a/config/locales/crowdin/js-pt-PT.yml +++ b/config/locales/crowdin/js-pt-PT.yml @@ -352,7 +352,7 @@ pt-PT: standard: learn_about_link: https://www.openproject.org/blog/openproject-13-4-release/ new_features_html: > - The release contains various new features and improvements:
  • GitLab integration (originally developed by Community contributors)
  • Advanced features for custom project lists
  • Advanced features for the Meetings module
  • Virus scanning functionality with ClamAV (Enterprise add-on)
  • PDF Export: Lists in table cells are supported
  • WebAuthn/FIDO/U2F is added as a second factor
  • More languages added to the default available set
+ A versão contém uma série de novas funcionalidades e melhorias:
  • Integração com o GitLab (originalmente desenvolvido por colaboradores da Comunidade)
  • Funcionalidades avançadas para listas de projetos personalizadas
  • Funcionalidades avançadas para o módulo Reuniões
  • Funcionalidade de verificação de vírus com o ClamAV (suplemento Enterprise)
  • Exportação de PDF: são suportadas listas em células de tabela
  • O WebAuthn/FIDO/U2F foi adicionado como um segundo fator
  • Foram adicionados mais idiomas ao conjunto disponível por defeito
ical_sharing_modal: title: "Subscrever o calendário" inital_setup_error_message: "Ocorreu um erro ao recuperar os dados." diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index 2d13220c268a..a8e3c504a32b 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -26,8 +26,8 @@ lt: no_results_title_text: Šiuo periodu projekte nieko neįvyko. admin: plugins: - no_results_title_text: There are currently no plugins installed. - no_results_content_text: See our integrations and plugins page for more information. + no_results_title_text: Šio metu nėra įdiegtų priedų. + no_results_content_text: Daugiau informacijos rasite mūsų integracijų ir priedų puslapyje. custom_styles: color_theme: "Spalvų tema" color_theme_custom: "(Pasirinktinis)" @@ -1591,7 +1591,7 @@ lt: journals: changes_retracted: "Pakeitimai buvo atšaukti." caused_changes: - default_attribute_written: "Read-only attributes written" + default_attribute_written: "Tik-skaitymo atributai įrašyti" dates_changed: "Datos pasikeitė" system_update: "OpenProject sistemos atnaujinimas" cause_descriptions: diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index 9d1607598e7c..4d14ff8c5de0 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -2718,7 +2718,7 @@ pt-BR: Se o CORS estiver habilitado, essas são as origens que têm permissão para acessar a API OpenProject.
Por favor, verifique a documentação na header da Origin sobre como especificar os valores esperados. setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Se ativada, a API permitirá que os administradores escrevam atributos estáticos somente leitura durante a criação, como os campos data/hora createdAt e updatedAt.
Aviso: Esta configuração tem um caso de uso para, por exemplo, importar dados, mas permite que os administradores personifiquem a criação de itens como outros usuários. No entanto, todas as solicitações de criação estão sendo registradas com o verdadeiro autor.
Para obter mais informações sobre atributos e recursos compatíveis, consulte o site %{api_documentation_link}. setting_apiv3_max_page_size: "Tamanho máximo da página de API" setting_apiv3_max_page_instructions_html: > Defina o tamanho máximo de página com o qual a API responderá. Não será possível realizar solicitações de API que retornem mais valores em uma única página.
Aviso: Somente altere este valor se tiver certeza do motivo de sua necessidade. Definir um valor alto resultará em impactos significativos no desempenho, enquanto um valor menor que as opções por página causará erros nas visualizações paginadas. @@ -3097,7 +3097,7 @@ pt-BR: status_user_and_brute_force: "%{user} e %{brute_force}" status_change: "Mudança de situação" text_change_disabled_for_provider_login: "O nome é definido por seu fornecedor de início de sessão e, desta forma, não pode ser alterado." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "O nome e o e-mail são definidos pelo LDAP e, portanto, não podem ser alterados." unlock: "Desbloquear" unlock_and_reset_failed_logins: "Desbloquear e redefinir logins com falha" version_status_closed: "fechado" diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index 395aad9a25f9..5e674f4ae333 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -26,14 +26,14 @@ pt-PT: no_results_title_text: Não ocorreu nenhuma atividade neste projeto dentro deste espaço de tempo. admin: plugins: - no_results_title_text: There are currently no plugins installed. - no_results_content_text: See our integrations and plugins page for more information. + no_results_title_text: Atualmente não há plugins instalados. + no_results_content_text: Para mais informações, consulte a nossa página de integrações e plugins. custom_styles: color_theme: "Cores do tema" color_theme_custom: "(Personalizado)" colors: - primary-button-color: "Primary button" - accent-color: "Accent" + primary-button-color: "Botão principal" + accent-color: "Destaque" header-bg-color: "Fundo do cabeçalho" header-item-bg-hover-color: "Fundo do cabeçalho ao passar o rato" header-item-font-color: "Fonte do cabeçalho" @@ -52,8 +52,8 @@ pt-PT: enterprise_more_info: "Nota: o logótipo usado estará acessível ao público." manage_colors: "Editar opções de selecção de cor" instructions: - primary-button-color: "Strong accent color, used for the most important button on a screen." - accent-color: "Color for links and other decently highlighted elements." + primary-button-color: "Cor de contraste forte, utilizada no botão mais importante de um ecrã." + accent-color: "Cor para as ligações e outros elementos bem realçados." header-item-bg-hover-color: "Cor de fundo de itens do cabeçalho clicáveis quando se passa com o rato por cima." header-item-font-color: "Cor da fonte dos itens clicáveis do cabeçalho." header-item-font-hover-color: "Cor da fonte de itens clicáveis do cabeçalho quando se passa com o mouse por cima." @@ -1527,7 +1527,7 @@ pt-PT: postgres_migration: "A migrar a sua instalação para PostgreSQL" user_guides: "Guia do Utilizador" faq: "FAQ (Perguntas Mais Frequentes)" - impressum: "Legal notice" + impressum: "Aviso legal" glossary: "Glossário" shortcuts: "Atalhos" blog: "Blog de OpenProject" @@ -1538,7 +1538,7 @@ pt-PT: journals: changes_retracted: "As mudanças foram retraídas." caused_changes: - default_attribute_written: "Read-only attributes written" + default_attribute_written: "Escrita de atributos só de leitura" dates_changed: "Datas alteradas" system_update: "Atualização do sistema OpenProject:" cause_descriptions: @@ -1919,7 +1919,7 @@ pt-PT: label_member_new: "Novo Membro" label_member_all_admin: "(Todas as funções devidas ao status do administrador)" label_member_plural: "Membros" - label_membership_plural: "Memberships" + label_membership_plural: "Associações" lable_membership_added: "Membro adicionado" lable_membership_updated: "Membro atualizado" label_menu_badge: @@ -2714,9 +2714,9 @@ pt-PT: setting_apiv3_cors_origins: "Partilha de recursos entre origens (CORS) permitidos pela API V3" setting_apiv3_cors_origins_text_html: > Se o CORS estiver habilitado, estas são as origens que têm permissão para aceder ao API OpenProject.
Por favor, verifique a documentação no cabeçalho da Origem sobre como especificar os valores esperados. - setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" + setting_apiv3_write_readonly_attributes: "Acesso de escrita a atributos só de leitura" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Se estiver ativada, a API irá permitir que os administradores escrevam atributos estáticos só de leitura durante a criação, como os carimbos de data/hora createdAt e updatedAt.
Aviso: esta definição tem um caso de utilização para, por exemplo, importar dados, mas permite que os administradores se façam passar por outros utilizadores na criação de itens. No entanto, todos os pedidos de criação são registados com o verdadeiro autor.
Para obter mais informações sobre os atributos e os recursos suportados, consulte a %{api_documentation_link}. setting_apiv3_max_page_size: "Tamanho máximo da página de API" setting_apiv3_max_page_instructions_html: > Defina o tamanho máximo de página com a qual a API vai responder. Não será possível fazer solicitações de API que retornem mais valores numa única página.
Aviso: Altere este valor apenas se tiver certeza do motivo pelo qual precisa de o fazer. Definir um valor alto resulta em impactos significativos no desempenho, enquanto um valor menor que as opções por página causa erros nas visualizações paginadas. @@ -2740,7 +2740,7 @@ pt-PT: setting_app_subtitle: "Subtítulo da aplicação" setting_app_title: "Título da aplicação" setting_attachment_max_size: "Tamanho máx. do anexo" - setting_antivirus_scan_mode: "Scan mode" + setting_antivirus_scan_mode: "Modo de digitalização" setting_antivirus_scan_action: "Ação para ficheiro infetado" setting_autofetch_changesets: "Alterações do repositório autofetch" setting_autologin: "Autologin" @@ -2750,7 +2750,7 @@ pt-PT: setting_brute_force_block_minutes: "Tempo de bloqueio do utilizador" setting_cache_formatted_text: "Colocar formatação do texto na memória cache" setting_use_wysiwyg_description: "Selecione para ativar o editor WYSIWYG CKEditor5 para todos os utilizadores por padrão. CKEditor tem funcionalidade limitada para GFM Markdown." - setting_column_options: "Default work package lists columns" + setting_column_options: "Colunas de listas de pacotes de trabalho predefinidas" setting_commit_fix_keywords: "Palavras-chave fixas" setting_commit_logs_encoding: "Codificação das mensagens de confirmação" setting_commit_logtime_activity_id: "Atividade para tempo registado" @@ -2772,7 +2772,7 @@ pt-PT: setting_emails_header: "Cabeçalho de e-mails" setting_email_login: "Utilizar o email como início de sessão" setting_enabled_scm: "SCM ativado" - setting_enabled_projects_columns: "Columns in a projects list displayed by default" + setting_enabled_projects_columns: "Colunas numa lista de projetos apresentadas por predefinição" setting_feeds_enabled: "Permitir Feeds" setting_ical_enabled: "Ativar subscrições do iCalendar" setting_feeds_limit: "Limite de conteúdo feed" @@ -2842,25 +2842,25 @@ pt-PT: Defina uma lista de extensões de ficheiros e/ou de tipos mime para ficheiros carregados.
Insira extensões de ficheiro (por exemplo, %{ext_example}) ou tipos mime (ex., %{mime_example}).
Deixe em branco para permitir que qualquer tipo de ficheiro seja carregado. Vários valores permitidos (uma linha para cada valor). antivirus: title: "Verificação de vírus" - clamav_ping_failed: "Failed to connect the the ClamAV daemon. Double-check the configuration and try again." + clamav_ping_failed: "Não foi possível ligar o daemon do ClamAV. Verifique novamente a configuração e tente novamente." remaining_quarantined_files_html: > - Virus scanning has been disbled. %{file_count} remain in quarantine. To review quarantined files, please visit this link: %{link} + A verificação de vírus foi desativada. %{file_count} permanece(m) em quarentena. Para rever os ficheiros colocados em quarentena, aceda a esta ligação: %{link} remaining_scan_complete_html: > - Remaining files have been scanned. There are %{file_count} in quarantine. You are being redirected to the quarantine page. Use this page to delete or override quarantined files. + Os restantes ficheiros foram verificados. %{file_count} encontram-se em quarentena. Está a ser redirecionado para a página de quarentena. Utilize esta página para eliminar ou substituir ficheiros em quarentena. remaining_rescanned_files: > - Virus scanning has been enabled successfuly. There are %{file_count} that were uploaded previously and still need to be scanned. This process has been scheduled in the background. The files will remain accessible during the scan. + A verificação de vírus foi ativada com êxito. Existem %{file_count} que foram carregados anteriormente e que ainda precisam de ser verificados. Este processo foi agendado em segundo plano. Os ficheiros permanecerão acessíveis durante a verificação. upsale: - description: "Ensure uploaded files in OpenProject are scanned for viruses before being accessible by other users." + description: "Assegure-se que os ficheiros carregados no OpenProject são verificados quanto à presença de vírus antes de serem acessíveis a outros utilizadores." actions: delete: "Eliminar ficheiro" quarantine: "Colocar o ficheiro em quarentena" instructions_html: > Seleccione a ação a executar para os ficheiros em que foi detectado um vírus:
  • %{quarantine_option}: Coloque o ficheiro em quarentena, impedindo os utilizadores de lhe acederem. Os administradores podem rever e eliminar ficheiros em quarentena na administração.
  • %{delete_option}: Elimine o ficheiro imediatamente.
modes: - clamav_socket_html: Enter the socket to the clamd daemon, e.g., %{example} - clamav_host_html: Enter the hostname and port to the clamd daemon separated by colon. e.g., %{example} + clamav_socket_html: Introduza o socket para o daemon clamd, por exemplo, %{example} + clamav_host_html: Introduza o nome do anfitrião e a porta para o daemon clamd separados por dois pontos. Por exemplo, %{example} description_html: > - Select the mode in which the antivirus scanner integration should operate.
  • %{disabled_option}: Uploaded files are not scanned for viruses.
  • %{socket_option}: You have set up ClamAV on the same server as OpenProject and the scan daemon clamd is running in the background
  • %{host_option}: You are streaming files to an external virus scanning host.
+ Selecione o modo em que a integração do scanner antivírus deve funcionar.
  • %{disabled_option}: os ficheiros carregados não são verificados quanto a vírus.
  • %{socket_option}: configurou o ClamAV no mesmo servidor que o OpenProject e o daemon de verificação clamd está a ser executado em segundo plano
  • %{host_option}: está a transmitir ficheiros para um anfitrião externo de verificação de vírus.
brute_force_prevention: "Bloqueio de utilizador automatizado" date_format: first_date_of_week_and_year_set: > @@ -3095,7 +3095,7 @@ pt-PT: status_user_and_brute_force: "%{user} e %{brute_force}" status_change: "Alteração de estado" text_change_disabled_for_provider_login: "O nome é definido pelo seu fornecedor de início de sessão, e portanto, não pode ser alterado." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "O nome e o e-mail são definidos pelo LDAP e não podem, portanto, ser alterados." unlock: "Desbloquear" unlock_and_reset_failed_logins: "Desbloquear e redefinir logins errados" version_status_closed: "fechado" diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index 760fbb414b6a..5cf7bbd444e0 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -422,7 +422,7 @@ zh-TW: copy_failed: "此工作項目無法被複製" move_failed: "此工作項目無法被移動" could_not_be_saved: "以下文檔無法被保存" - none_could_be_saved: "None of the %{total} work packages could be updated." + none_could_be_saved: " %{total} 工作項目無法更新" x_out_of_y_could_be_saved: "%{failing} out of the %{total} work packages could not be updated while %{success} could." selected_because_descendants: "While %{selected} work packages where selected, in total %{total} work packages are affected which includes descendants." descendant: "descendant of selected" diff --git a/modules/avatars/config/locales/crowdin/js-hu.yml b/modules/avatars/config/locales/crowdin/js-hu.yml index 26c3e7f0d945..378bab010131 100644 --- a/modules/avatars/config/locales/crowdin/js-hu.yml +++ b/modules/avatars/config/locales/crowdin/js-hu.yml @@ -10,5 +10,5 @@ hu: Töltsön fel saját, 128x128 képpont méretű profilképet. A nagyobb fájlok átméretezésre és levágásra kerülnek, hogy illeszkedjenek a méretkorláthoz. Feltöltés előtt megjelenik a profilképének előnézete, miután kiválasztott egy képet. error_image_too_large: "Fájl mérete túl nagy." wrong_file_format: "A megengedett formátumok: jpg, png, gif" - empty_file_error: "Kérjük, töltsön fel egy érvényes képet (jpg, png, gif)." + empty_file_error: "Kérjük, érvényes képet töltsön fel (jpg, png, gif)." diff --git a/modules/backlogs/config/locales/crowdin/hu.yml b/modules/backlogs/config/locales/crowdin/hu.yml index 83c2483d9f84..b3823af7176d 100644 --- a/modules/backlogs/config/locales/crowdin/hu.yml +++ b/modules/backlogs/config/locales/crowdin/hu.yml @@ -64,8 +64,8 @@ hu: properties: "Tulajdonságok" rebuild: "Újraépítés" rebuild_positions: "Pozíciók újraépítése" - remaining_hours: "Hátralévő munka" - remaining_hours_ideal: "Hátralévő munka (ideális)" + remaining_hours: "Fennmaradó órák" + remaining_hours_ideal: "Fennmaradó órák (ideális)" show_burndown_chart: "Napi teendő ábra" story: "Sztori" story_points: "Story pontok" @@ -141,18 +141,18 @@ hu: points_resolved: "points resolved" points_to_accept: "points not accepted" points_to_resolve: "points not resolved" - project_module_backlogs: "Backlogs" + project_module_backlogs: "Elvégzendő feladatok" rb_label_copy_tasks: "Copy work packages" rb_label_copy_tasks_all: "Mind" rb_label_copy_tasks_none: "None" rb_label_copy_tasks_open: "Open" rb_label_link_to_original: "Include link to original story" - remaining_hours: "hátralévő munka" + remaining_hours: "Fennmaradó órák" required_burn_rate_hours: "required burn rate (hours)" required_burn_rate_points: "required burn rate (points)" - todo_work_package_description: "%{summary}: %{url}\n%{description}" + todo_work_package_description: "%{összegzés}: %{url}\n%{leírás}" todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Column in backlog" + version_settings_display_label: "Hátralévő feladatok oszlopa" version_settings_display_option_left: "balra" version_settings_display_option_none: "none" version_settings_display_option_right: "jobbra" diff --git a/modules/backlogs/config/locales/crowdin/ms.yml b/modules/backlogs/config/locales/crowdin/ms.yml index 6e749610528d..2e7280ed6150 100644 --- a/modules/backlogs/config/locales/crowdin/ms.yml +++ b/modules/backlogs/config/locales/crowdin/ms.yml @@ -152,7 +152,7 @@ ms: required_burn_rate_points: "kadar pembakaran yang diperlukan (mata)" todo_work_package_description: "%{summary}: %{url}\n%{description}" todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Kolum dalam backlog" + version_settings_display_label: "Kolum dalam tunggakan" version_settings_display_option_left: "kiri" version_settings_display_option_none: "tiada" version_settings_display_option_right: "kanan" diff --git a/modules/bim/config/locales/crowdin/cs.seeders.yml b/modules/bim/config/locales/crowdin/cs.seeders.yml index 1c42c958c73f..c7e36b8c2348 100644 --- a/modules/bim/config/locales/crowdin/cs.seeders.yml +++ b/modules/bim/config/locales/crowdin/cs.seeders.yml @@ -578,10 +578,10 @@ cs: * ... item_3: subject: Odesílání modelu BIM - description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. + description: Tento typ je hierarchicky nadřazený typům "Clash" a "Request", představuje tedy obecnou poznámku. item_5: subject: Koordinace, první cyklus - description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. + description: Tento typ je hierarchicky nadřazeným typem "Clash" a "Request", proto představuje obecnou poznámku. children: item_0: subject: Koordinace různých modelů BIM diff --git a/modules/bim/config/locales/crowdin/js-ms.yml b/modules/bim/config/locales/crowdin/js-ms.yml index 0cdd0ea94493..24303eab2ae8 100644 --- a/modules/bim/config/locales/crowdin/js-ms.yml +++ b/modules/bim/config/locales/crowdin/js-ms.yml @@ -4,26 +4,26 @@ ms: bcf: label_bcf: 'BCF' import: 'Import' - import_bcf_xml_file: 'Import fail BCF XML (BCF version 2.1)' + import_bcf_xml_file: 'Import fail BCF XML (BCF versi 2.1)' export: 'Eksport' - export_bcf_xml_file: 'Eksport fail BCF XML (BCF version 2.1)' + export_bcf_xml_file: 'Eksport fail BCF XML (BCF versi 2.1)' viewpoint: 'Sudut pandangan' add_viewpoint: 'Tambah sudut pandangan' show_viewpoint: 'Tunjuk sudut pandangan' delete_viewpoint: 'Padam sudut pandangan' management: "Pengurusan BCF\n" - refresh: 'Refresh' - refresh_work_package: 'Refresh work package' + refresh: 'Muat semula ' + refresh_work_package: 'Muat semula pakej kerja' ifc_models: - empty_warning: "This project does not yet have any IFC models." - use_this_link_to_manage: "Use this link to upload and manage your IFC models" - keyboard_input_disabled: "Viewer does not have keyboard controls. Click on the viewer to give keyboard control to the viewer." + empty_warning: "Projek ini belum mempunyai sebarang model IFC." + use_this_link_to_manage: "Guna pautan ini untuk kemas kini dan urus model IFC anda" + keyboard_input_disabled: "Pemerhati tidak ada kawalan papan kekunci. Klik pada pemerhati untuk memberikan kawalan papan kekunci kepada pemerhati." models: - ifc_models: 'IFC models' + ifc_models: 'Model IFC' views: - viewer: 'Viewer' - split: 'Viewer and table' - split_cards: 'Viewer and cards' + viewer: 'Pemerhati' + split: 'Pemerhati dan jadual' + split_cards: 'Pemerhati dan kad' revit: revit_add_in: "Revit Add-In" - revit_add_in_settings: "Revit Add-In settings" + revit_add_in_settings: "Seting Revit Add-In" diff --git a/modules/bim/config/locales/crowdin/ms.seeders.yml b/modules/bim/config/locales/crowdin/ms.seeders.yml index 95f86dffaca7..fd3a24e2e9c8 100644 --- a/modules/bim/config/locales/crowdin/ms.seeders.yml +++ b/modules/bim/config/locales/crowdin/ms.seeders.yml @@ -135,9 +135,9 @@ ms: 4. _Cipta dan kemaskini carta Gantt_: → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. 5. _Mengaktifkan modul lanjutan_: → Pergi ke [Seting projek → Modul]({{opSetting:base_url}}/projects/demo-construction-project/settings/modules). 6. _Lihat paparan til untuk dapatkan gambar keseluruhan isu BCF anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) - 7. _Pemangkin kerja? Semak board kami yang baru:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-construction-project/boards) + 7. _Pemangkin kerja? Semak board kami yang baru:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-construction-project/boards) - Disini anda akan jumpa [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Disini anda akan jumpa [Panduan Pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami jika anda mempunyai sebarang soalan atau memerlukan sokongan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -185,14 +185,14 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) dalam navigasi projek. - 2. _Lihat kerja dalam projek anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 3. _Cipta satu pakej kerja baharu:_ → Pergi ke [Work packages → Create]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 4. _Cipta dan kemaskini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi project. - 5. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). - 6. _Pemangkin kerja? Cipta satu panel baharu:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) dalam navigasi projek. + 2. _Lihat kerja dalam projek anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 3. _Cipta satu pakej kerja baru:_ → Pergi ke [Pakej kerja → Cipta]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 4. _Cipta dan kemaskini carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 5. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). + 6. _Pemangkin kerja? Cipta satu board baharu:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) - Di sini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Di sini anda akan menemui [Panduan Pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -209,7 +209,7 @@ ms: description: |- Permulaan projek menandakan permulaan projek di dalam syarikat anda. Setiap orang yang menjadi sebahagian daripada projek ini perlu dijemput untuk menyertai taklimat pertama projek. - Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Gantt chart]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). + Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Carta Gantt]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). item_1: subject: Penilaian asas description: Jenis ini adalah induk secara hierarki bagi jenis "Konflik" dan "Permintaan", oleh itu mewakili catatan umum. @@ -318,7 +318,7 @@ ms: ## Huraian * Menyediakan tapak untuk projek - * Kumpulkan pasukan + * Kumpulkan pasukan * ... item_1: subject: Asas @@ -388,7 +388,7 @@ ms: * Menyiapkan pemasangan sistem perkhidmatan bangunan * Menyiapkan pembinaan dalaman - * MEnyiapkan muka bangunan + * Menyiapkan muka bangunan * ... item_6: subject: Majlis perasmian rumah baru @@ -439,17 +439,17 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-bim-project/members) dalam navigasi projek. + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-bim-project/members) dalam navigasi projek. 2. _Muat naik dan paparkan model 3d dalam format IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) dalam navigasi projek. 3. _Cipta dan urus isu BCF yang berkaitan dalam model IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) → Cipta. - 4. _Lihat kerja dalam projek anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 5. _Cipta satu pakej pekerjaan baharu:_ → Pergi ke [Work packages → Create]({{opSetting:base_url}}/projects/demo-bim-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 6. _Cipta dan kemaskini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 7. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules]({{opSetting:base_url}}/projects/demo-bim-project/settings/modules). - 8. _Semak paparan til untuk mendapatkan gambaran keseluruhan mengenai isu-isu BCF anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) - 9. _Pemangkin kerja? Cipta satu panel baru:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-bim-project/boards) - - Di sini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + 4. _Lihat kerja dalam projek anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 5. _Cipta satu pakej pekerjaan baharu:_ → Pergi ke [Pakej kerja → Cipta]({{opSetting:base_url}}/projects/demo-bim-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 6. _Cipta dan kemaskini carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 7. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul]({{opSetting:base_url}}/projects/demo-bim-project/settings/modules). + 8. _Semak paparan til untuk mendapatkan gambaran keseluruhan mengenai isu-isu BCF anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) + 9. _Pemangkin kerja? Cipta satu panel baru:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-bim-project/boards) + + Di sini anda akan menemui [Panduan pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -466,7 +466,7 @@ ms: description: |- Permulaan projek menandakan permulaan projek di dalam syarikat anda. Setiap orang yang menjadi sebahagian daripada projek ini perlu dijemput untuk menyertai taklimat pertama projek. - Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Gantt chart]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). + Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Carta Gantt]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). item_1: subject: Penyediaan projek description: Jenis ini adalah induk bagi jenis "Clash" dan "Request", oleh itu mewakili catatan umum. @@ -715,16 +715,16 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-bcf-management-project/members?show_add_members=true) dalam navigasi projek. + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-bcf-management-project/members?show_add_members=true) dalam navigasi projek. 2. _Muat naik dan lihat model 3d dalam format IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) dalam navigasi projek. 3. _Cipta dan urus isu BCF yang dihubung secara langsung dalam model IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) → Cipta. 4. _Lihat fail BCF dalam projek anda:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22status%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) dalam navigasi projek. 5. _Muatkan fail BCF anda:_ → Pergi ke [BCF → Import.]({{opSetting:base_url}}/projects/demo-bcf-management-project/issues/upload) - 6. _Cipta dan kemas kini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22days%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D) dalam navigasi projek. - 7. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules.]({{opSetting:base_url}}/projects/demo-bcf-management-project/settings/modules) - 8. _Anda suka pendekatan yang pantas ini? Cipta Board:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-bcf-management-project/boards). + 6. _Cipta dan kemas kini Carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22days%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D) dalam navigasi projek. + 7. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul.]({{opSetting:base_url}}/projects/demo-bcf-management-project/settings/modules) + 8. _Anda suka pendekatan yang pantas ini? Cipta Board:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-bcf-management-project/boards). - Disini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Disini anda akan menemui [Panduan pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: diff --git a/modules/bim/config/locales/crowdin/ms.yml b/modules/bim/config/locales/crowdin/ms.yml index 014d2278eca5..b845326d6795 100644 --- a/modules/bim/config/locales/crowdin/ms.yml +++ b/modules/bim/config/locales/crowdin/ms.yml @@ -74,10 +74,10 @@ ms: oauth: scopes: bcf_v2_1: "Akses penuh untuk BCF v2.1 API" - bcf_v2_1_text: "Aplikasi akan terima akses baca & tulis penuh di OpenProject BCF API v2.1 untuk meaksanakan tindakan bagi pihak anda." + bcf_v2_1_text: "Aplikasi akan terima akses baca & tulis penuh di OpenProject BCF API v2.1 untuk melaksanakan tindakan bagi pihak anda." activerecord: models: - bim/ifc_models/ifc_model: "model IFC" + bim/ifc_models/ifc_model: "Model IFC" attributes: bim/ifc_models/ifc_model: ifc_attachment: "Fail IFC" @@ -88,7 +88,7 @@ ms: bim/ifc_models/ifc_model: attributes: base: - ifc_attachment_missing: "Tiada fail ifc yang dilampirkan" + ifc_attachment_missing: "Tiada fail IFC yang dilampirkan" invalid_ifc_file: "Fail yang ditetapkan adalah fail IFC yang tidak sah." bim/bcf/viewpoint: bitmaps_not_writable: "bitmaps tidak boleh ditulis kerana belum dilaksanakan." @@ -99,7 +99,7 @@ ms: invalid_orthogonal_camera: "kamera_serenjang adalah tidak sah." invalid_perspective_camera: "kamera_perspektif adalah tidak sah." mismatching_guid: "The guid in the json_viewpoint does not match the model's guid." - no_json: "Is not a well structured json." + no_json: "Bukan json yang tersusun dengan baik." snapshot_type_unsupported: "jenis_snapshot perlu sama ada 'png' atau 'jpg'." snapshot_data_blank: "data_snapshot perlu disediakan." unsupported_key: "Properti json yang tidak disokong disertakan." @@ -108,16 +108,16 @@ ms: ifc_models: label_ifc_models: 'Model IFC' label_new_ifc_model: 'Model IFC baharu' - label_show_defaults: 'Show defaults' + label_show_defaults: 'Tunjukkan defaults' label_default_ifc_models: 'Model default IFC ' label_edit_defaults: 'Edit defaults' no_defaults_warning: - title: 'No IFC model was set as default for this project.' + title: 'Tiada model IFC yang ditetapkan sebagai default untuk projek ini.' check_1: 'Semak bahawa anda telah memuat naik sekurang-kurangnya satu model IFC.' check_2: 'Semak bahawa salah satu model IFC ditetapkan sebagai "Default".' no_results: "Tiada model IFC yang telah dimuat naik di dalam projek ini." conversion_status: - label: 'Pemprosesan?' + label: 'Proses?' pending: 'Dalam proses' processing: 'Sedang di proses' completed: 'Selesai' @@ -127,7 +127,7 @@ ms: flash_messages: upload_successful: 'Muat naik berjaya. Akan diproses dan sedia untuk digunakan dalam masa beberapa minit.' conversion: - missing_commands: "Arahan penukar IFC berikut hilang pada sistem ini: %{names}" + missing_commands: "Arahan converter IFC berikut hilang dari sistem ini: %{names}" project_module_ifc_models: "Model IFC" permission_view_ifc_models: "Paparkan model IFC" permission_manage_ifc_models: "Import dan urus model IFC" diff --git a/modules/boards/config/locales/crowdin/js-ms.yml b/modules/boards/config/locales/crowdin/js-ms.yml index 46be2a3f4228..c1ac594abee6 100644 --- a/modules/boards/config/locales/crowdin/js-ms.yml +++ b/modules/boards/config/locales/crowdin/js-ms.yml @@ -42,15 +42,15 @@ ms: action_text: > Board dengan senarai yang disaring di atribut %{attribute}. Pemindahan pakej kerja ke senarai lain akan mengemas kini atribut mereka. action_text_subprojects: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + Board dengan kolum automatik untuk subprojek. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. action_text_subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + Board dengan kolum automatik untuk sub-elements. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. action_text_status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + Board gaya kanban asas dengan kolum untuk status seperti Untuk Dilakukan, Dalam Pelaksanaan, Selesai. action_text_assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + Board dengan kolum automatik berdasarkan pengguna yang ditentukan. Ideal untuk penghantaran pakej kerja. action_text_version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + Board dengan kolum automatik berdasarkan versi atribut. Ideal untuk merancang pembangunan produk. action_type: assignee: wakil status: status diff --git a/modules/boards/config/locales/crowdin/ms.yml b/modules/boards/config/locales/crowdin/ms.yml index ca810b0ecf2b..b2e8a041615e 100644 --- a/modules/boards/config/locales/crowdin/ms.yml +++ b/modules/boards/config/locales/crowdin/ms.yml @@ -4,16 +4,16 @@ ms: name: "OpenProject Boards" description: "Provides board views." permission_show_board_views: "Paparkan papan" - permission_manage_board_views: "Manage boards" - project_module_board_view: "Boards" + permission_manage_board_views: "Urus board" + project_module_board_view: "Board" boards: label_board: "Board" - label_boards: "Boards" - label_create_new_board: "Create new board" - label_board_type: "Board type" + label_boards: "Board" + label_create_new_board: "Cipta board baru" + label_board_type: "Jenis board" board_types: free: "Asas\n" - action: "Action board (%{attribute})" + action: "Board tindakan (%{attribute})" board_type_attributes: assignee: Wakil status: Status @@ -23,17 +23,17 @@ ms: basic: "Asas\n" board_type_descriptions: basic: > - Start from scratch with a blank board. Manually add cards and columns to this board. + Bermula dari awal dengan board kosong. Tambah kad dan kolum secara manual kepada board ini. status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + Board gaya kanban asas dengan kolum untuk status seperti Untuk Dilakukan, Dalam Pelaksanaan, Selesai. assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + Board dengan kolum automatik berdasarkan pengguna yang ditentukan. Ideal untuk penghantaran pakej kerja. version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + Board dengan kolum automatik berdasarkan versi atribut. Ideal untuk merancang pembangunan produk. subproject: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + Board dengan kolum automatik untuk subprojek. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + Board dengan kolum automatik untuk sub-elements. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. upsale: teaser_text: 'Would you like to automate your workflows with Boards? Advanced boards are an Enterprise add-on. Please upgrade to a paid plan.' upgrade: 'Naik taraf sekarang' diff --git a/modules/budgets/config/locales/crowdin/js-ms.yml b/modules/budgets/config/locales/crowdin/js-ms.yml index 8236365cf878..e9634ba74563 100644 --- a/modules/budgets/config/locales/crowdin/js-ms.yml +++ b/modules/budgets/config/locales/crowdin/js-ms.yml @@ -23,4 +23,4 @@ ms: js: work_packages: properties: - costObject: "Bajet" + costObject: "Anggaran" diff --git a/modules/budgets/config/locales/crowdin/ms.yml b/modules/budgets/config/locales/crowdin/ms.yml index 8fe78c4bb612..eb41079dc982 100644 --- a/modules/budgets/config/locales/crowdin/ms.yml +++ b/modules/budgets/config/locales/crowdin/ms.yml @@ -47,32 +47,32 @@ ms: attributes: budget: "Anggaran" button_add_budget_item: "Tambah kos yang dirancang" - button_add_budget: "Tambah bajet" + button_add_budget: "Tambah anggaran" button_add_cost_type: "Tambah jenis kos" - button_cancel_edit_budget: "Batalkan bajet penyuntingan" + button_cancel_edit_budget: "Batalkan anggaran penyuntingan" button_cancel_edit_costs: "Batalkan kos penyuntingan" caption_labor: "Buruh" caption_labor_costs: "Kos buruh sebenar" caption_material_costs: "Kos unit sebenar" - budgets_title: "Bajet" + budgets_title: "Anggaran" events: - budget: "Bajet yang diedit" - help_click_to_edit: "Klik sini untuk edit." + budget: "Anggaran yang diedit" + help_click_to_edit: "Klik di sini untuk mengedit" help_currency_format: "Format nilai mata wang yang dipaparkan. %n diganti dengan nilai mata wang, %u diganti dengan unit mata wang." help_override_rate: "Masukkan nilai disini untuk gantikan kadar default." - label_budget: "Bajet" - label_budget_new: "Bajet baru" - label_budget_plural: "Bajet" - label_budget_id: "Bajet #%{id}" - label_deliverable: "Bajet" + label_budget: "Anggaran" + label_budget_new: "Anggaran baru" + label_budget_plural: "Anggaran" + label_budget_id: "Anggaran #%{id}" + label_deliverable: "Anggaran" label_example_placeholder: 'e.g., %{decimal}' - label_view_all_budgets: "Paparkan semua bajet" + label_view_all_budgets: "Paparkan semua anggaran" label_yes: "Ya" notice_budget_conflict: "Work packages must be of the same project." - notice_no_budgets_available: "Tiada bajet available." + notice_no_budgets_available: "Tiada bajet yang tersedia." permission_edit_budgets: "Edit bajet" - permission_view_budgets: "Paparkan bajet" - project_module_budgets: "Bajet" - text_budget_reassign_to: "Pindahkan mereka ke bajet ini" - text_budget_delete: "Padam bajet dari semua pakej kerja" - text_budget_destroy_assigned_wp: "Terdapat %{count} pakej kerja ditugaskan untuk bajet ini. Apa yang anda ingin lakukan?" + permission_view_budgets: "Paparkan anggaran" + project_module_budgets: "Anggaran" + text_budget_reassign_to: "Pindahkan mereka ke anggaran ini:" + text_budget_delete: "Padam anggaran dari semua pakej kerja" + text_budget_destroy_assigned_wp: "Terdapat %{count} pakej kerja ditugaskan untuk anggaran ini. Apa yang anda ingin lakukan?" diff --git a/modules/calendar/config/locales/crowdin/hu.yml b/modules/calendar/config/locales/crowdin/hu.yml index e63bc3b355ab..b6e857068d17 100644 --- a/modules/calendar/config/locales/crowdin/hu.yml +++ b/modules/calendar/config/locales/crowdin/hu.yml @@ -4,9 +4,9 @@ hu: name: "OpenProject Calendar" description: "Provides calendar views." label_calendar: "Naptár" - label_calendar_plural: "Naptárak" - label_new_calendar: "Új naptár" - permission_view_calendar: "Naptárak megtekintése" - permission_manage_calendars: "Naptárak kezelése" + label_calendar_plural: "Naptár" + label_new_calendar: "Új esemény" + permission_view_calendar: "Naptár bejegyzések megtekintése" + permission_manage_calendars: "Naptár kezelés" permission_share_calendars: "Feliratkozás az iCalendars-ra" project_module_calendar_view: "Naptárak" diff --git a/modules/calendar/config/locales/crowdin/js-hu.yml b/modules/calendar/config/locales/crowdin/js-hu.yml index 4970b7e3381a..73cf2edc1572 100644 --- a/modules/calendar/config/locales/crowdin/js-hu.yml +++ b/modules/calendar/config/locales/crowdin/js-hu.yml @@ -4,5 +4,5 @@ hu: calendar: create_new: 'Új naptár létrehozása' title: 'Naptár' - too_many: 'Összesen %{count} munkacsomag van, de csak %{max} jeleníthető meg.' + too_many: 'Összesen %{százalék_szám} munkacsomag van, de csak %{maximum} jeleníthető meg.' unsaved_title: 'Névtelen naptár' diff --git a/modules/costs/config/locales/crowdin/hu.yml b/modules/costs/config/locales/crowdin/hu.yml index 72ac4c3d60be..7801700ffd6b 100644 --- a/modules/costs/config/locales/crowdin/hu.yml +++ b/modules/costs/config/locales/crowdin/hu.yml @@ -34,7 +34,7 @@ hu: unit: "Egység neve" unit_plural: "Többes számú egység neve" work_package: - costs_by_type: "Elköltött egység" + costs_by_type: "Elhasznált egységek" labor_costs: "Munkaerő költségek" material_costs: "Egység költségek" overall_costs: "Összes költség" @@ -104,7 +104,7 @@ hu: label_work_package_filter_add: "Munkacsomag szűrő hozzáadása" label_kind: "Típus" label_less_or_equal: "<=" - label_log_costs: "Költségek naplózása" + label_log_costs: "Naplózott költségek" label_no: "Nem" label_option_plural: "Beállítások" label_overall_costs: "Összes költség" diff --git a/modules/gitlab_integration/config/locales/crowdin/cs.yml b/modules/gitlab_integration/config/locales/crowdin/cs.yml index becd05dc9d7a..e98796a3fe4a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/cs.yml +++ b/modules/gitlab_integration/config/locales/crowdin/cs.yml @@ -40,7 +40,7 @@ cs: merge_request_closed_comment: > **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR sloučeno:** Požadavek na sloučení %{mr_number} [%{mr_title}](%{mr_url}) pro [%{repository}](%{repository_url}) byl sloučen [%{gitlab_user}](%{gitlab_user_url}). merge_request_reopened_comment: > **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). note_commit_referenced_comment: > diff --git a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml index f01e53cdbfc6..f90aa30e9a4a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml @@ -33,18 +33,18 @@ pt-PT: label: Criar MR description: Crie Pedido de Fusão copy_menu: - label: Git snippets + label: Fragmentos de código Git description: Copiar pedaços de código git para área de transferência git_actions: branch_name: Nome do ramo - commit_message: Commit message + commit_message: Mensagem de confirmação cmd: Criar ramificação com a confirmação vazia - title: Quick snippets for Git + title: Fragmentos de código rápido para Git copy_success: '✅ Copiado!' copy_error: '❌ A cópia falhou!' tab_issue: - empty: 'There are no issues linked yet. Link an existing issue by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the issue title/description or create a new issue.' + empty: 'Ainda não existem problemas associados. Associe um problema existente utilizando o código OP#%{wp_id} (ou PP#%{wp_id} para ligações privadas) no título/descrição do problema ou crie um problema novo.' tab_mrs: - empty: 'There are no merge requests linked yet. Link an existing MR by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the MR title/description or create a new MR.' + empty: 'Ainda não existem pedidos de fusão associados. Associe um MR existente utilizando o código OP#%{wp_id} (ou PP#%{wp_id} para ligações privadas) no título/descrição do MR ou crie um novo MR.' gitlab_pipelines: Pipelines updated_on: Atualizado em diff --git a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml index 2281be87d617..8cfdaaaa5baf 100644 --- a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml @@ -33,35 +33,35 @@ pt-PT: labels: invalid_schema: "deve ser um conjunto de hashes com as chaves: cor, título" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "Mostrar conteúdo GitLab" gitlab_integration: merge_request_opened_comment: > - **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **MR aberto:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi aberto por [%{gitlab_user}](%{gitlab_user_url}). merge_request_closed_comment: > - **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **MR fechado:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi fechado por [%{gitlab_user}](%{gitlab_user_url}). merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR fundido:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi fundido por [%{gitlab_user}](%{gitlab_user_url}). merge_request_reopened_comment: > - **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **MR reaberto:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi reaberto por [%{gitlab_user}](%{gitlab_user_url}). note_commit_referenced_comment: > - **Referenced in Commit:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in a Commit Note [%{commit_id}](%{commit_url}) on [%{repository}](%{repository_url}): %{commit_note} + **Referenciado em Confirmação:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP numa Nota de Confirmação [%{commit_id}](%{commit_url}) em [%{repository}](%{repository_url}): %{commit_note} note_mr_referenced_comment: > - **Referenced in MR:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Referenciado em MR:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Pedido de Fusão %{mr_number} [%{mr_title}](%{mr_url}) em [%{repository}](%{repository_url}): %{mr_note} note_mr_commented_comment: > - **Commented in MR:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Comentado em RM:** [%{gitlab_user}](%{gitlab_user_url}) comentou este WP no Pedido de Fusão %{mr_number} [%{mr_title}](%{mr_url}) em [%{repository}](%{repository_url}): %{mr_note} note_issue_referenced_comment: > - **Referenced in Issue:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Referenciado no Problema:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Problema %{issue_number} [%{issue_title}](%{issue_url}) em [%{repository}](%{repository_url}): %{issue_note} note_issue_commented_comment: > - **Commented in Issue:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Comentado no Problema:** [%{gitlab_user}](%{gitlab_user_url}) comentou este WP no Problema %{issue_number} [%{issue_title}](%{issue_url}) em [%{repository}](%{repository_url}): %{issue_note} note_snippet_referenced_comment: > - **Referenced in Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) on [%{repository}](%{repository_url}): %{snippet_note} + **Referenciado no Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) em [%{repository}](%{repository_url}): %{snippet_note} issue_opened_referenced_comment: > - **Issue Opened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Aberto:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi aberto por [%{gitlab_user}](%{gitlab_user_url}). issue_closed_referenced_comment: > - **Issue Closed:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Fechado:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi fechado por [%{gitlab_user}](%{gitlab_user_url}). issue_reopened_referenced_comment: > - **Issue Reopened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Reaberto:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi reaberto por [%{gitlab_user}](%{gitlab_user_url}). push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Enviado no MR:** [%{gitlab_user}](%{gitlab_user_url}) enviou [%{commit_number}](%{commit_url}) para [%{repository}](%{repository_url}) em %{commit_timestamp}: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Enviado no MR:** [%{gitlab_user}](%{gitlab_user_url}) enviou vários compromissos [%{commit_number}](%{commit_url}) para [%{repository}](%{repository_url}) em %{commit_timestamp}: %{commit_note} diff --git a/modules/grids/config/locales/crowdin/js-pt-PT.yml b/modules/grids/config/locales/crowdin/js-pt-PT.yml index 468f88f1cc3f..470c6a6212a3 100644 --- a/modules/grids/config/locales/crowdin/js-pt-PT.yml +++ b/modules/grids/config/locales/crowdin/js-pt-PT.yml @@ -8,7 +8,7 @@ pt-PT: text: "Alguns widgets, como o widget gráfico do pacote de trabalho, estão disponíveis apenas na edição Enterprise." link: 'Edição Enterprise.' widgets: - missing_permission: "You don't have the necessary permissions to view this widget." + missing_permission: "Não tem as permissões necessárias para visualizar este widget." custom_text: title: 'Texto personalizado' documents: diff --git a/modules/ldap_groups/config/locales/crowdin/ms.yml b/modules/ldap_groups/config/locales/crowdin/ms.yml index 0d13fb571b4c..e24a0ebb4176 100644 --- a/modules/ldap_groups/config/locales/crowdin/ms.yml +++ b/modules/ldap_groups/config/locales/crowdin/ms.yml @@ -51,8 +51,8 @@ ms: destroy: title: 'Keluarkan kumpulan yang diselaraskan %{name}' confirmation: "Jika anda teruskan, kumpulan yang diselaraskan %{name} dan semua pengguna %{users_count} yang diselaraskan melalui itu akan dikeluarkan." - info: "Note: The OpenProject group itself and members added outside this LDAP synchronization will not be removed." - verification: "Enter the group's name %{name} to verify the deletion." + info: "Perhatian: Kumpulan OpenProject itu sendiri dan ahli yang ditambah di luar penjanaan LDAP tidak akan dikeluarkan." + verification: "Masukkan nama kumpulan %{name} untuk mengesahkan pembuangan." help_text_html: | This module allows you to set up a synchronization between LDAP and OpenProject groups. It depends on LDAP groups need to use the groupOfNames / memberOf attribute set to be working with OpenProject. diff --git a/modules/meeting/config/locales/crowdin/js-ms.yml b/modules/meeting/config/locales/crowdin/js-ms.yml index a633717befab..d209653a73bb 100644 --- a/modules/meeting/config/locales/crowdin/js-ms.yml +++ b/modules/meeting/config/locales/crowdin/js-ms.yml @@ -21,4 +21,4 @@ #++ ms: js: - label_meetings: 'Meetings' + label_meetings: 'Mesyuarat' diff --git a/modules/meeting/config/locales/crowdin/ms.seeders.yml b/modules/meeting/config/locales/crowdin/ms.seeders.yml index 595a75234889..ffdf184cb36f 100644 --- a/modules/meeting/config/locales/crowdin/ms.seeders.yml +++ b/modules/meeting/config/locales/crowdin/ms.seeders.yml @@ -9,21 +9,21 @@ ms: demo-project: meetings: item_0: - title: Weekly + title: Mingguan meeting_agenda_items: item_0: - title: Good news + title: Berita baik item_1: - title: Updates from development team + title: Kemas kini dari pasukan pembangunan item_2: - title: Updates from product team + title: Kemas kini dari pasukan produk item_3: - title: Updates from marketing team + title: Kemas kini dari pasukan pemasaran item_4: - title: Updates from sales team + title: Kemas kini dari pasukan jualan item_5: - title: Review of quarterly goals + title: Penilaian matlamat suku tahunan item_6: - title: Core values feedback + title: Maklum balas nilai teras item_7: - title: General topics + title: Topik umum diff --git a/modules/meeting/config/locales/crowdin/ms.yml b/modules/meeting/config/locales/crowdin/ms.yml index a6f52fc90ea7..9ad233d4322b 100644 --- a/modules/meeting/config/locales/crowdin/ms.yml +++ b/modules/meeting/config/locales/crowdin/ms.yml @@ -28,156 +28,156 @@ ms: activerecord: attributes: meeting: - type: "Meeting type" - location: "Location" - duration: "Duration" - notes: "Notes" - participants: "Participants" + type: "Jenis mesyuarat" + location: "Lokasi" + duration: "Tempoh" + notes: "Nota" + participants: "Peserta" participant: - other: "%{count} Participants" - participants_attended: "Attendees" - participants_invited: "Invitees" - project: "Project" - start_date: "Date" - start_time: "Time" - start_time_hour: "Starting time" + other: "1 Peserta" + participants_attended: "Peserta" + participants_invited: "Jemputan" + project: "Projek" + start_date: "Tarikh" + start_time: "Masa" + start_time_hour: "Masa mula" meeting_agenda_items: - title: "Title" - author: "Responsible" - duration_in_minutes: "Duration (min)" - description: "Notes" + title: "Tajuk" + author: "Bertanggungjawab" + duration_in_minutes: "Tempoh (minit)" + description: "Nota" errors: messages: - invalid_time_format: "is not a valid time. Required format: HH:MM" + invalid_time_format: "bukan masa yang sah. Format yang diperlukan: JJ:MM" models: - structured_meeting: "Meeting (dynamic)" - meeting_agenda_item: "Agenda item" + structured_meeting: "Mesyuarat (dinamik)" + meeting_agenda_item: "Item agenda" meeting_agenda: "Agenda" - meeting_minutes: "Minutes" + meeting_minutes: "Minit mesyuarat" activity: filter: - meeting: "Meetings" - description_attended: "attended" - description_invite: "invited" + meeting: "Mesyuarat" + description_attended: "dihadiri" + description_invite: "dijemput" events: - meeting: Meeting edited - meeting_agenda: Meeting agenda edited - meeting_agenda_closed: Meeting agenda closed - meeting_agenda_opened: Meeting agenda opened - meeting_minutes: Meeting minutes edited - meeting_minutes_created: Meeting minutes created - error_notification_with_errors: "Failed to send notification. The following recipients could not be notified: %{recipients}" - label_meeting: "Meeting" - label_meeting_plural: "Meetings" - label_meeting_new: "New Meeting" - label_meeting_edit: "Edit Meeting" + meeting: Mesyuarat telah diedit + meeting_agenda: Agenda mesyuarat yang diedit + meeting_agenda_closed: Agenda mesyuarat ditutup + meeting_agenda_opened: Agenda mesyuarat dibuka + meeting_minutes: Minit mesyuarat diedit + meeting_minutes_created: Minit mesyuarat dicipta + error_notification_with_errors: "Gagal untuk hantar pemberitahuan. Penerima berikut tidak dapat diberitahu: %{recipients}" + label_meeting: "Mesyuarat" + label_meeting_plural: "Mesyuarat" + label_meeting_new: "Mesyuarat Baharu" + label_meeting_edit: "Edit mesyuarat" label_meeting_agenda: "Agenda" - label_meeting_minutes: "Minutes" - label_meeting_close: "Close" - label_meeting_open: "Open" - label_meeting_agenda_close: "Close the agenda to begin the Minutes" - label_meeting_date_time: "Date/Time" - label_meeting_diff: "Diff" - label_upcoming_meetings: "Upcoming meetings" - label_past_meetings: "Past meetings" - label_upcoming_meetings_short: "Upcoming" - label_past_meetings_short: "Past" - label_involvement: "Involvement" - label_upcoming_invitations: "Upcoming invitations" - label_past_invitations: "Past invitations" - label_attendee: "Attendee" - label_author: "Creator" - label_notify: "Send for review" - label_icalendar: "Send iCalendar" - label_icalendar_download: "Download iCalendar event" + label_meeting_minutes: "Minit" + label_meeting_close: "Tutup" + label_meeting_open: "Buka" + label_meeting_agenda_close: "Tutup agenda untuk mulakan minit" + label_meeting_date_time: "Tarikh/Masa" + label_meeting_diff: "Beza" + label_upcoming_meetings: "Mesyuarat akan datang" + label_past_meetings: "Mesyuarat lalu" + label_upcoming_meetings_short: "Akan Datang" + label_past_meetings_short: "Lalu" + label_involvement: "Penglibatan" + label_upcoming_invitations: "Jemputan akan datang" + label_past_invitations: "Jemputan lalu" + label_attendee: "Peserta" + label_author: "Pencipta" + label_notify: "Hantar untuk semakan" + label_icalendar: "Hantar iCalendar\n" + label_icalendar_download: "Muat turun acara dalam iCalendar" label_version: "Versi" - label_time_zone: "Time zone" - label_start_date: "Start date" + label_time_zone: "Zon waktu" + label_start_date: "Tarikh mula" meeting: copy: - title: "Copy meeting %{title}" - agenda: "Copy agenda" - agenda_text: "Copy the agenda of the old meeting" + title: "Salin mesyuarat %{title}" + agenda: "Salin agenda" + agenda_text: "Salin agenda mesyuarat lama" email: - open_meeting_link: "Open meeting" + open_meeting_link: "Buka mesyuarat" invited: - summary: "%{actor} has sent you an invitation for the meeting %{title}" + summary: "%{actor} menghantar anda jemputan untuk mesyuarat %{title}" rescheduled: header: "Meeting %{title} has been rescheduled" - summary: "Meeting %{title} has been rescheduled by %{actor}" - body: "The meeting %{title} has been rescheduled by %{actor}." - old_date_time: "Old date/time" - new_date_time: "New date/time" - label_mail_all_participants: "Send email to all participants" + summary: "Mesyuarat %{title} telah dijadual semula oleh %{actor}" + body: "Mesyuarat %{title} telah dijadual semula oleh %{actor}" + old_date_time: "Tarikh/masa lama" + new_date_time: "Tarikh/masa baru" + label_mail_all_participants: "Hantar emel ke semua peserta" types: - classic: 'Classic' - classic_text: 'Organize your meeting in a formattable text agenda and protocol.' - structured: 'Dynamic' - structured_text: 'Organize your meeting as a list of agenda items, optionally linking them to a work package.' - structured_text_copy: 'Copying a meeting will currently not copy the associated meeting agenda items, just the details' - copied: "Copied from Meeting #%{id}" - notice_successful_notification: "Notification sent successfully" - notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. - permission_create_meetings: "Create meetings" - permission_edit_meetings: "Edit meetings" - permission_delete_meetings: "Delete meetings" - permission_view_meetings: "View meetings" - permission_create_meeting_agendas: "Create meeting agendas" - permission_create_meeting_agendas_explanation: "Allows editing the Classic Meeting's agenda content." - permission_manage_agendas: "Manage agendas" - permission_manage_agendas_explanation: "Allows managing the Dynamic Meeting's agenda items." - permission_close_meeting_agendas: "Close agendas" - permission_send_meeting_agendas_notification: "Send review notification for agendas" - permission_create_meeting_minutes: "Manage minutes" - permission_send_meeting_minutes_notification: "Send review notification for minutes" - permission_meetings_send_invite: "Invite users to meetings" - permission_send_meeting_agendas_icalendar: "Send meeting agenda as calendar entry" - project_module_meetings: "Meetings" - text_duration_in_hours: "Duration in hours" - text_in_hours: "in hours" - text_meeting_agenda_for_meeting: 'agenda for the meeting "%{meeting}"' - text_meeting_closing_are_you_sure: "Are you sure you want to close the meeting agenda?" - text_meeting_agenda_open_are_you_sure: "This will overwrite all changes in the minutes! Do you want to continue?" - text_meeting_minutes_for_meeting: 'minutes for the meeting "%{meeting}"' - text_notificiation_invited: "This mail contains an ics entry for the meeting below:" - text_meeting_empty_heading: "Your meeting is empty" - text_meeting_empty_description_1: "Start by adding agenda items below. Each item can be as simple as just a title, but you can also add additional details like duration and notes." - text_meeting_empty_description_2: "You can also add references to existing work packages. When you do, related notes will automatically be visible in the work package's \"Meetings\" tab." - label_meeting_empty_action: "Add agenda item" - label_meeting_actions: "Meeting actions" - label_meeting_edit_title: "Edit meeting title" - label_meeting_delete: "Delete meeting" - label_meeting_created_by: "Created by" - label_meeting_last_updated: "Last updated" - label_agenda_item_undisclosed_wp: "Work package #%{id} not visible" - label_agenda_item_deleted_wp: "Deleted work package reference" - label_agenda_item_actions: "Agenda items actions" - label_agenda_item_move_to_top: "Move to top" - label_agenda_item_move_to_bottom: "Move to bottom" - label_agenda_item_move_up: "Move up" - label_agenda_item_move_down: "Move down" - label_agenda_item_add_notes: "Add notes" - label_meeting_details: "Meeting details" - label_meeting_details_edit: "Edit meeting details" - label_meeting_state_open: "Open" - label_meeting_state_closed: "Closed" - label_meeting_reopen_action: "Reopen meeting" - label_meeting_close_action: "Close meeting" - text_meeting_open_description: "This meeting is open. You can add/remove agenda items and edit them as you please. After the meeting is over, close it to lock it." - text_meeting_closed_description: "This meeting is closed. You cannot add/remove agenda items anymore." - label_meeting_manage_participants: "Manage participants" - label_meeting_no_participants: "No participants" - label_meeting_show_hide_participants: "Show/hide %{count} more" - label_meeting_show_all_participants: "Show all" - label_meeting_add_participants: "Add participants" - text_meeting_not_editable_anymore: "This meeting is not editable anymore." - text_meeting_not_present_anymore: "This meeting was deleted. Please select another meeting." - label_add_work_package_to_meeting_dialog_title: "Add work package to meeting" - label_add_work_package_to_meeting_dialog_button: "Add to meeting" + classic: 'Klasik' + classic_text: 'Susun mesyuarat anda dalam agenda teks boleh format dan protokol.' + structured: 'Dinamik' + structured_text: 'Susun mesyuarat anda sebagai senarai butiran agenda, secara pilihan menghubungnya kepada pakej kerja.' + structured_text_copy: 'Menyalin mesyuarat pada masa ini tidak akan menyalin item agenda mesyuarat yang berkaitan, hanya butiran sahaja' + copied: "Salin dari mesyuarat #%{id}" + notice_successful_notification: "Pemberitahuan berjaya dihantar" + notice_timezone_missing: Tiada zon waktu yang ditetapkan dan %{zone} adalah andaian.Untuk pilih zon waktu anda, sila klik sini. + permission_create_meetings: "Cipta mesyuarat" + permission_edit_meetings: "Edit mesyuarat" + permission_delete_meetings: "Hapuskan Mesyuarat" + permission_view_meetings: "Lihat Mesyuarat" + permission_create_meeting_agendas: "Cipta agenda mesyuarat" + permission_create_meeting_agendas_explanation: "Benarkan kandungan agenda Mesyuarat Klasik untuk diedit" + permission_manage_agendas: "Urus agenda" + permission_manage_agendas_explanation: "Benarkan pengurusan item agenda Dinamik Mesyuarat." + permission_close_meeting_agendas: "Tutup agenda" + permission_send_meeting_agendas_notification: "Hantar pemberitahuan semakan untuk agenda" + permission_create_meeting_minutes: "Urus minit mesyuarat" + permission_send_meeting_minutes_notification: "Hantar pemberitahuan semakan untuk minit mesyuarat" + permission_meetings_send_invite: "Jemput pengguna ke mesyuarat" + permission_send_meeting_agendas_icalendar: "Hantar agenda mesyuarat sebagai kemasukan kalendar" + project_module_meetings: "Mesyuarat" + text_duration_in_hours: "Jangka masa dalam jam" + text_in_hours: "dalam jam" + text_meeting_agenda_for_meeting: 'agenda untuk mesyuarat "%{meeting}"' + text_meeting_closing_are_you_sure: "Adakah anda pasti anda ingin menutup agenda mesyuarat?" + text_meeting_agenda_open_are_you_sure: "Ini akan menggantikan semua perubahan dalam minit mesyuarat! Adakah anda ingin teruskan?" + text_meeting_minutes_for_meeting: 'minit untuk mesyuarat "%{meeting}"' + text_notificiation_invited: "Mel ini mengandungi kemasukan ics untuk mesyuarat dibawah:" + text_meeting_empty_heading: "Mesyuarat anda kosong" + text_meeting_empty_description_1: "Mula dengan menambah item agenda dibawah. Setiap item boleh jadi seringkas tajuk, tapi anda juga boleh tambah butiran tambahan seperti jangka masa dan nota." + text_meeting_empty_description_2: "Anda juga boleh menambah rujukan ke pakej kerja yang sedia ada. Apabila anda lakukan, nota berkaitan secara automatik akan boleh dilihat dalam tab \"Mesyuarat\" pakej kerja." + label_meeting_empty_action: "Tambah item agenda" + label_meeting_actions: "Tindakan mesyuarat" + label_meeting_edit_title: "Edit tajuk mesyuarat" + label_meeting_delete: "Hapuskan mesyuarat" + label_meeting_created_by: "Dicipta oleh" + label_meeting_last_updated: "Kemas kini terakhir" + label_agenda_item_undisclosed_wp: "Pakej kerja #%{id} tidak kelihatan" + label_agenda_item_deleted_wp: "Hapuskan rujukan pakej kerja" + label_agenda_item_actions: "Tindakan item agenda" + label_agenda_item_move_to_top: "Alih ke paling atas" + label_agenda_item_move_to_bottom: "Alih ke paling bawah" + label_agenda_item_move_up: "Alihkan ke atas" + label_agenda_item_move_down: "Gerak ke bawah" + label_agenda_item_add_notes: "Tambah nota" + label_meeting_details: "Butiran mesyuarat" + label_meeting_details_edit: "Edit butiran mesyuarat" + label_meeting_state_open: "Buka" + label_meeting_state_closed: "Ditutup" + label_meeting_reopen_action: "Buka semula mesyuarat" + label_meeting_close_action: "Tutup mesyuarat" + text_meeting_open_description: "Mesyuarat ini terbuka. Anda boleh tambah/keluarkan item agenda dan edit mereka sesuka hati. Setelah mesyuarat berakhir, tutup mesyuarat untuk kunci." + text_meeting_closed_description: "Mesyuarat ini ditutup. Anda tidak boleh tambah/keluarkan item agenda lagi." + label_meeting_manage_participants: "Urus peserta" + label_meeting_no_participants: "Tiada peserta" + label_meeting_show_hide_participants: "Tunjuk/hilangkan %{count} lagi" + label_meeting_show_all_participants: "Tunjukkan semua" + label_meeting_add_participants: "Tambah peserta" + text_meeting_not_editable_anymore: "Mesyuarat ini tidak boleh diedit lagi." + text_meeting_not_present_anymore: "Mesyuarat ini telah dihapuskan. Sila pilih mesyuarat lain." + label_add_work_package_to_meeting_dialog_title: "Tambah pakej kerja ke mesyuarat" + label_add_work_package_to_meeting_dialog_button: "Tambah ke mesyuarat" label_meeting_selection_caption: "It's only possible to add this work package to upcoming or ongoing open meetings." - text_add_work_package_to_meeting_description: "A work package can be added to one or multiple meetings for discussion. Any notes concerning it are also visible here." - text_agenda_item_no_notes: "No notes provided" - text_agenda_item_not_editable_anymore: "This agenda item is not editable anymore." - text_work_package_has_no_upcoming_meeting_agenda_items: "This work package is not scheduled in an upcoming meeting agenda yet." - text_work_package_add_to_meeting_hint: "Use the \"Add to meeting\" button to add this work package to an upcoming meeting." - text_work_package_has_no_past_meeting_agenda_items: "This work package was not mentioned in a past meeting." + text_add_work_package_to_meeting_description: "Pakej kerja boleh ditambah kepada satu atau beberapa mesyuarat untuk perbincangan. Sebarang nota berkaitan juga kelihatan di sini." + text_agenda_item_no_notes: "Tiada nota disediakan" + text_agenda_item_not_editable_anymore: "Item agenda tidak boleh diedit lagi." + text_work_package_has_no_upcoming_meeting_agenda_items: "Pakej kerja ini belum dijadualkan dalam mana-mana agenda mesyuarat akan datang lagi." + text_work_package_add_to_meeting_hint: "Guna butang \"Tambah ke mesyuarat\" untuk tambah pakej kerja ini ke mesyuarat akan datang." + text_work_package_has_no_past_meeting_agenda_items: "Pakej kerja ini masih belum diutarakan dalam mesyuarat yang lalu." diff --git a/modules/my_page/config/locales/crowdin/js-ms.yml b/modules/my_page/config/locales/crowdin/js-ms.yml index 80596bfff1f9..d831c4788ca9 100644 --- a/modules/my_page/config/locales/crowdin/js-ms.yml +++ b/modules/my_page/config/locales/crowdin/js-ms.yml @@ -1,4 +1,4 @@ ms: js: my_page: - label: "My page" + label: "Halaman saya" diff --git a/modules/openid_connect/config/locales/crowdin/ms.yml b/modules/openid_connect/config/locales/crowdin/ms.yml index b9415982cf55..54b0a4a5da91 100644 --- a/modules/openid_connect/config/locales/crowdin/ms.yml +++ b/modules/openid_connect/config/locales/crowdin/ms.yml @@ -3,15 +3,15 @@ ms: name: "OpenProject OpenID Connect" description: "Adds OmniAuth OpenID Connect strategy providers to Openproject." logout_warning: > - You have been logged out. The contents of any form you submit may be lost. Please [log in]. + Anda telah log keluar. Apa-apa bentuk kandungan yang anda hantar mungkin hilang. Sila [log masuk]. activemodel: attributes: openid_connect/provider: - name: Name - display_name: Display name + name: Nama + display_name: Nama paparan identifier: Identifier - secret: Secret - scope: Scope + secret: "Sulit\n" + scope: Skop limit_self_registration: Limit self registration openid_connect: menu_title: OpenID providers diff --git a/modules/storages/config/locales/crowdin/cs.yml b/modules/storages/config/locales/crowdin/cs.yml index efe2768f6411..5a466dc9f299 100644 --- a/modules/storages/config/locales/crowdin/cs.yml +++ b/modules/storages/config/locales/crowdin/cs.yml @@ -63,7 +63,7 @@ cs: one_drive: Povolit OpenProject přístup k Azure datům pomocí OAuth pro připojení OneDrive/Sharepoint. redirect_uri_incomplete: one_drive: Dokončete nastavení správným přesměrováním URI. - confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? + confirm_replace_oauth_application: Tato akce obnoví aktuální OAuth přihlašovací údaje. Po potvrzení budete muset znovu zadat přihlašovací údaje u poskytovatele úložiště a všichni vzdálení uživatelé budou muset znovu autorizovat proti OpenProject Jste si jisti, že chcete pokračovat? confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? delete_warning: input_delete_confirmation: Zadejte název úložiště souboru %{file_storage} pro potvrzení odstranění. diff --git a/modules/storages/config/locales/crowdin/pt-PT.yml b/modules/storages/config/locales/crowdin/pt-PT.yml index 791b6d5cb368..02ad74fc47f9 100644 --- a/modules/storages/config/locales/crowdin/pt-PT.yml +++ b/modules/storages/config/locales/crowdin/pt-PT.yml @@ -53,8 +53,8 @@ pt-PT: complete_without_setup: Concluir sem isso done_complete_setup: Concluído, terminar configuração done_continue: Concluído, continuar - replace_oauth_application: Replace OpenProject OAuth - replace_oauth_client: Replace %{provider_type} OAuth + replace_oauth_application: Substituir OpenProject OAuth + replace_oauth_client: Substituir OAuth %{provider_type} save_and_continue: Guardar e continuar select_folder: Selecionar pasta configuration_checks: @@ -63,8 +63,8 @@ pt-PT: one_drive: Permitir que o OpenProject aceda aos dados do Azure utilizando o OAuth para ligar o OneDrive/Sharepoint. redirect_uri_incomplete: one_drive: Conclua a configuração com o redirecionamento correto da URI. - confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? - confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? + confirm_replace_oauth_application: Esta ação irá repor as credenciais OAuth atuais. Após a confirmação, terá de voltar a introduzir as credenciais no fornecedor de armazenamento e todos os utilizadores remotos terão de autorizar novamente o OpenProject. Tem a certeza de que pretende continuar? + confirm_replace_oauth_client: Esta ação irá repor as credenciais OAuth atuais. Depois de confirmar, terá de introduzir novas credenciais do fornecedor de armazenamento e todos os utilizadores terão de autorizar novamente em %{provider_type}. Tem a certeza de que pretende continuar? delete_warning: input_delete_confirmation: Introduza o nome do ficheiro de armazenamento %{file_storage} para confirmar a eliminação. irreversible_notice: A eliminação de um ficheiro de armazenamento é uma ação irreversível. diff --git a/modules/team_planner/config/locales/crowdin/js-pt-PT.yml b/modules/team_planner/config/locales/crowdin/js-pt-PT.yml index 42e5651f80e1..8bbd64599656 100644 --- a/modules/team_planner/config/locales/crowdin/js-pt-PT.yml +++ b/modules/team_planner/config/locales/crowdin/js-pt-PT.yml @@ -12,8 +12,8 @@ pt-PT: remove_assignee: 'Remover responsável' two_weeks: '2 semanas' one_week: '1 semana' - four_weeks: '4-week' - eight_weeks: '8-week' + four_weeks: '4 semanas' + eight_weeks: '8 semanas' work_week: 'Semana de trabalho' today: 'Hoje' drag_here_to_remove: 'Arraste aqui para remover o responsável e as datas de início e término.' diff --git a/modules/two_factor_authentication/config/locales/crowdin/cs.yml b/modules/two_factor_authentication/config/locales/crowdin/cs.yml index 0c74cb1f2ef9..0d4321082095 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/cs.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/cs.yml @@ -114,11 +114,11 @@ cs: is_default_cannot_delete: "Zařízení je označeno jako výchozí a nemůže být odstraněno z důvodu aktivní bezpečnostní politiky. Před smazáním označte jiné zařízení jako výchozí." not_existing: "Žádné 2FA zařízení nebylo zaregistrováno pro váš účet." 2fa_from_input: Zadejte prosím kód z Vašeho %{device_name} pro ověření Vaší identity. - 2fa_from_webauthn: Please provide the WebAuthn device %{device_name}. If it is USB based make sure to plug it in and touch it. Then click the sign in button. + 2fa_from_webauthn: Uveďte prosím zařízení WebAuthn %{device_name}. Pokud je založeno na USB, nezapomeňte jej připojit a dotknout se jej. Poté klikněte na tlačítko Přihlásit se. webauthn: title: "WebAuthn" description: Use Web Authentication to register a FIDO2 device (like a YubiKey) or the secure enclave of your mobile device as a second factor. - further_steps: After you have chosen a name, you can click the Continue button. Your browser will prompt you to present your WebAuthn device. When you have done so, you are done registering the device. + further_steps: Po zvolení jména můžete kliknout na tlačítko Pokračovat. Váš prohlížeč vás vyzve, abyste prezentovali vaše WebAuthn zařízení. Až tak učiníte, jste zařízení zaregistrovali. totp: title: "Použít autentifikátor založený na aplikaci" provisioning_uri: "Poskytování URI" diff --git a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml index 713fd96f3f0b..0d0e7ba01d25 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml @@ -76,7 +76,7 @@ pt-PT: button_register_mobile_phone_for_user: "Registre o celular" text_2fa_enabled: "Em cada login, este utilizador será solicitado a inserir um token OTP do seu dispositivo padrão 2FA." text_2fa_disabled: "O utilizador não configurou um dispositivo 2FA através da sua página \"A minha conta\"" - only_sms_allowed: "Only SMS delivery can be set up for other users." + only_sms_allowed: "Apenas o envio de SMS pode ser configurado para outros utilizadores." upsale: title: "Autenticação de dois fatores" description: "Reforce a segurança da sua instância do OpenProject ao oferecer (ou reforçar) a autenticação de dois fatores a todos os membros do projeto." @@ -114,12 +114,12 @@ pt-PT: failed_to_delete: "Falha ao excluir o dispositivo 2FA." is_default_cannot_delete: "O dispositivo está marcado como padrão e não pode ser excluído devido a uma política de segurança ativa. Marque outro dispositivo como padrão antes de excluir." not_existing: "Nenhum dispositivo 2FA foi registrado para sua conta." - 2fa_from_input: Please enter the code from your %{device_name} to verify your identity. - 2fa_from_webauthn: Please provide the WebAuthn device %{device_name}. If it is USB based make sure to plug it in and touch it. Then click the sign in button. + 2fa_from_input: Introduza o código do seu %{device_name} para verificar a sua identidade. + 2fa_from_webauthn: Indique o dispositivo WebAuthn %{device_name}. Se for baseado em USB, certifique-se de que o liga e toca nele. Em seguida, clique no botão de início de sessão. webauthn: title: "WebAuthn" - description: Use Web Authentication to register a FIDO2 device (like a YubiKey) or the secure enclave of your mobile device as a second factor. - further_steps: After you have chosen a name, you can click the Continue button. Your browser will prompt you to present your WebAuthn device. When you have done so, you are done registering the device. + description: Utilize a Autenticação Web para registar um dispositivo FIDO2 (como uma YubiKey) ou o enclave seguro do seu dispositivo móvel como um segundo fator. + further_steps: Depois de ter escolhido um nome, pode clicar no botão Continuar. O seu navegador irá pedir-lhe para apresentar o seu dispositivo WebAuthn. Quando o tiver feito, o registo do dispositivo está concluído. totp: title: "Use o seu autenticador baseado em aplicativos" provisioning_uri: "URI de provisionamento" From 2a7e8fbf59a766517f0ce575692634efe080d443 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Fri, 22 Mar 2024 03:09:43 +0000 Subject: [PATCH 196/218] update locales from crowdin [ci skip] --- config/locales/crowdin/cs.yml | 4 +- config/locales/crowdin/js-pt-PT.yml | 2 +- config/locales/crowdin/pt-PT.yml | 24 +- .../avatars/config/locales/crowdin/js-hu.yml | 2 +- .../backlogs/config/locales/crowdin/hu.yml | 12 +- .../backlogs/config/locales/crowdin/ms.yml | 2 +- .../bim/config/locales/crowdin/cs.seeders.yml | 4 +- modules/bim/config/locales/crowdin/js-ms.yml | 6 +- .../bim/config/locales/crowdin/ms.seeders.yml | 54 ++-- modules/bim/config/locales/crowdin/ms.yml | 16 +- .../boards/config/locales/crowdin/js-ms.yml | 10 +- modules/boards/config/locales/crowdin/ms.yml | 24 +- .../budgets/config/locales/crowdin/js-ms.yml | 2 +- modules/budgets/config/locales/crowdin/ms.yml | 34 +-- .../calendar/config/locales/crowdin/hu.yml | 8 +- .../calendar/config/locales/crowdin/js-hu.yml | 2 +- modules/costs/config/locales/crowdin/hu.yml | 4 +- .../config/locales/crowdin/cs.yml | 2 +- .../config/locales/crowdin/js-pt-PT.yml | 10 +- .../config/locales/crowdin/pt-PT.yml | 32 +-- .../grids/config/locales/crowdin/js-pt-PT.yml | 2 +- .../ldap_groups/config/locales/crowdin/ms.yml | 4 +- .../meeting/config/locales/crowdin/js-ms.yml | 2 +- .../config/locales/crowdin/ms.seeders.yml | 18 +- modules/meeting/config/locales/crowdin/ms.yml | 268 +++++++++--------- .../my_page/config/locales/crowdin/js-ms.yml | 2 +- .../config/locales/crowdin/ms.yml | 10 +- .../storages/config/locales/crowdin/cs.yml | 2 +- .../storages/config/locales/crowdin/pt-PT.yml | 8 +- .../config/locales/crowdin/cs.yml | 4 +- .../config/locales/crowdin/pt-PT.yml | 10 +- 31 files changed, 292 insertions(+), 292 deletions(-) diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index 95c47f6c9b87..1afce766a9e0 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -2921,7 +2921,7 @@ cs: clamav_socket_html: Zadejte soket démona clamd, např. %{example}. clamav_host_html: Zadejte název hostitele a port démona clamd oddělený dvojtečkou. např. %{example} description_html: > - Select the mode in which the antivirus scanner integration should operate.
  • %{disabled_option}: Uploaded files are not scanned for viruses.
  • %{socket_option}: You have set up ClamAV on the same server as OpenProject and the scan daemon clamd is running in the background
  • %{host_option}: You are streaming files to an external virus scanning host.
+ Vyberte režim, ve kterém by měla fungovat integrace antivirového scanneru.
  • %{disabled_option}: Nahrané soubory nejsou naskenovány pro viry.
  • %{socket_option}: Nastavili jste ClamAV na stejném serveru jako OpenProject a scan daemon clamd běží na pozadí
  • %{host_option}: Vysíláte soubory do externího hostitele pro skenování virů.
brute_force_prevention: "Automatizované blokování uživatelů" date_format: first_date_of_week_and_year_set: > @@ -3231,7 +3231,7 @@ cs: text_empty_search_header: "Nenašli jsme žádné odpovídající výsledky." text_empty_state_description: "Pracovní balíček zatím nebyl s nikým sdílen." text_empty_state_header: "Není sdíleno" - text_user_limit_reached: "Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package." + text_user_limit_reached: "Přidáním dalších uživatelů bude aktuální limit překročen. Pro zvýšení limitu uživatelů kontaktujte správce, abyste zajistili přístup externích uživatelů k tomuto pracovnímu balíčku." text_user_limit_reached_admins: 'Adding additional users will exceed the current limit. Please upgrade your plan to be able to add more users.' warning_user_limit_reached: > Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package. diff --git a/config/locales/crowdin/js-pt-PT.yml b/config/locales/crowdin/js-pt-PT.yml index e3d53bf65607..d6ad4659f73a 100644 --- a/config/locales/crowdin/js-pt-PT.yml +++ b/config/locales/crowdin/js-pt-PT.yml @@ -352,7 +352,7 @@ pt-PT: standard: learn_about_link: https://www.openproject.org/blog/openproject-13-4-release/ new_features_html: > - The release contains various new features and improvements:
  • GitLab integration (originally developed by Community contributors)
  • Advanced features for custom project lists
  • Advanced features for the Meetings module
  • Virus scanning functionality with ClamAV (Enterprise add-on)
  • PDF Export: Lists in table cells are supported
  • WebAuthn/FIDO/U2F is added as a second factor
  • More languages added to the default available set
+ A versão contém uma série de novas funcionalidades e melhorias:
  • Integração com o GitLab (originalmente desenvolvido por colaboradores da Comunidade)
  • Funcionalidades avançadas para listas de projetos personalizadas
  • Funcionalidades avançadas para o módulo Reuniões
  • Funcionalidade de verificação de vírus com o ClamAV (suplemento Enterprise)
  • Exportação de PDF: são suportadas listas em células de tabela
  • O WebAuthn/FIDO/U2F foi adicionado como um segundo fator
  • Foram adicionados mais idiomas ao conjunto disponível por defeito
ical_sharing_modal: title: "Subscrever o calendário" inital_setup_error_message: "Ocorreu um erro ao recuperar os dados." diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index 19612cd37802..ef005ebb0f01 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -1921,7 +1921,7 @@ pt-PT: label_member_new: "Novo Membro" label_member_all_admin: "(Todas as funções devidas ao status do administrador)" label_member_plural: "Membros" - label_membership_plural: "Memberships" + label_membership_plural: "Associações" lable_membership_added: "Membro adicionado" lable_membership_updated: "Membro atualizado" label_menu_badge: @@ -2739,7 +2739,7 @@ pt-PT: setting_app_subtitle: "Subtítulo da aplicação" setting_app_title: "Título da aplicação" setting_attachment_max_size: "Tamanho máx. do anexo" - setting_antivirus_scan_mode: "Scan mode" + setting_antivirus_scan_mode: "Modo de digitalização" setting_antivirus_scan_action: "Ação para ficheiro infetado" setting_autofetch_changesets: "Alterações do repositório autofetch" setting_autologin: "Autologin" @@ -2749,7 +2749,7 @@ pt-PT: setting_brute_force_block_minutes: "Tempo de bloqueio do utilizador" setting_cache_formatted_text: "Colocar formatação do texto na memória cache" setting_use_wysiwyg_description: "Selecione para ativar o editor WYSIWYG CKEditor5 para todos os utilizadores por padrão. CKEditor tem funcionalidade limitada para GFM Markdown." - setting_column_options: "Default work package lists columns" + setting_column_options: "Colunas de listas de pacotes de trabalho predefinidas" setting_commit_fix_keywords: "Palavras-chave fixas" setting_commit_logs_encoding: "Codificação das mensagens de confirmação" setting_commit_logtime_activity_id: "Atividade para tempo registado" @@ -2771,7 +2771,7 @@ pt-PT: setting_emails_header: "Cabeçalho de e-mails" setting_email_login: "Utilizar o email como início de sessão" setting_enabled_scm: "SCM ativado" - setting_enabled_projects_columns: "Columns in a projects list displayed by default" + setting_enabled_projects_columns: "Colunas numa lista de projetos apresentadas por predefinição" setting_feeds_enabled: "Permitir Feeds" setting_ical_enabled: "Ativar subscrições do iCalendar" setting_feeds_limit: "Limite de conteúdo feed" @@ -2841,25 +2841,25 @@ pt-PT: Defina uma lista de extensões de ficheiros e/ou de tipos mime para ficheiros carregados.
Insira extensões de ficheiro (por exemplo, %{ext_example}) ou tipos mime (ex., %{mime_example}).
Deixe em branco para permitir que qualquer tipo de ficheiro seja carregado. Vários valores permitidos (uma linha para cada valor). antivirus: title: "Verificação de vírus" - clamav_ping_failed: "Failed to connect the the ClamAV daemon. Double-check the configuration and try again." + clamav_ping_failed: "Não foi possível ligar o daemon do ClamAV. Verifique novamente a configuração e tente novamente." remaining_quarantined_files_html: > - Virus scanning has been disbled. %{file_count} remain in quarantine. To review quarantined files, please visit this link: %{link} + A verificação de vírus foi desativada. %{file_count} permanece(m) em quarentena. Para rever os ficheiros colocados em quarentena, aceda a esta ligação: %{link} remaining_scan_complete_html: > - Remaining files have been scanned. There are %{file_count} in quarantine. You are being redirected to the quarantine page. Use this page to delete or override quarantined files. + Os restantes ficheiros foram verificados. %{file_count} encontram-se em quarentena. Está a ser redirecionado para a página de quarentena. Utilize esta página para eliminar ou substituir ficheiros em quarentena. remaining_rescanned_files: > - Virus scanning has been enabled successfuly. There are %{file_count} that were uploaded previously and still need to be scanned. This process has been scheduled in the background. The files will remain accessible during the scan. + A verificação de vírus foi ativada com êxito. Existem %{file_count} que foram carregados anteriormente e que ainda precisam de ser verificados. Este processo foi agendado em segundo plano. Os ficheiros permanecerão acessíveis durante a verificação. upsale: - description: "Ensure uploaded files in OpenProject are scanned for viruses before being accessible by other users." + description: "Assegure-se que os ficheiros carregados no OpenProject são verificados quanto à presença de vírus antes de serem acessíveis a outros utilizadores." actions: delete: "Eliminar ficheiro" quarantine: "Colocar o ficheiro em quarentena" instructions_html: > Seleccione a ação a executar para os ficheiros em que foi detectado um vírus:
  • %{quarantine_option}: Coloque o ficheiro em quarentena, impedindo os utilizadores de lhe acederem. Os administradores podem rever e eliminar ficheiros em quarentena na administração.
  • %{delete_option}: Elimine o ficheiro imediatamente.
modes: - clamav_socket_html: Enter the socket to the clamd daemon, e.g., %{example} - clamav_host_html: Enter the hostname and port to the clamd daemon separated by colon. e.g., %{example} + clamav_socket_html: Introduza o socket para o daemon clamd, por exemplo, %{example} + clamav_host_html: Introduza o nome do anfitrião e a porta para o daemon clamd separados por dois pontos. Por exemplo, %{example} description_html: > - Select the mode in which the antivirus scanner integration should operate.
  • %{disabled_option}: Uploaded files are not scanned for viruses.
  • %{socket_option}: You have set up ClamAV on the same server as OpenProject and the scan daemon clamd is running in the background
  • %{host_option}: You are streaming files to an external virus scanning host.
+ Selecione o modo em que a integração do scanner antivírus deve funcionar.
  • %{disabled_option}: os ficheiros carregados não são verificados quanto a vírus.
  • %{socket_option}: configurou o ClamAV no mesmo servidor que o OpenProject e o daemon de verificação clamd está a ser executado em segundo plano
  • %{host_option}: está a transmitir ficheiros para um anfitrião externo de verificação de vírus.
brute_force_prevention: "Bloqueio de utilizador automatizado" date_format: first_date_of_week_and_year_set: > diff --git a/modules/avatars/config/locales/crowdin/js-hu.yml b/modules/avatars/config/locales/crowdin/js-hu.yml index 26c3e7f0d945..378bab010131 100644 --- a/modules/avatars/config/locales/crowdin/js-hu.yml +++ b/modules/avatars/config/locales/crowdin/js-hu.yml @@ -10,5 +10,5 @@ hu: Töltsön fel saját, 128x128 képpont méretű profilképet. A nagyobb fájlok átméretezésre és levágásra kerülnek, hogy illeszkedjenek a méretkorláthoz. Feltöltés előtt megjelenik a profilképének előnézete, miután kiválasztott egy képet. error_image_too_large: "Fájl mérete túl nagy." wrong_file_format: "A megengedett formátumok: jpg, png, gif" - empty_file_error: "Kérjük, töltsön fel egy érvényes képet (jpg, png, gif)." + empty_file_error: "Kérjük, érvényes képet töltsön fel (jpg, png, gif)." diff --git a/modules/backlogs/config/locales/crowdin/hu.yml b/modules/backlogs/config/locales/crowdin/hu.yml index 83c2483d9f84..b3823af7176d 100644 --- a/modules/backlogs/config/locales/crowdin/hu.yml +++ b/modules/backlogs/config/locales/crowdin/hu.yml @@ -64,8 +64,8 @@ hu: properties: "Tulajdonságok" rebuild: "Újraépítés" rebuild_positions: "Pozíciók újraépítése" - remaining_hours: "Hátralévő munka" - remaining_hours_ideal: "Hátralévő munka (ideális)" + remaining_hours: "Fennmaradó órák" + remaining_hours_ideal: "Fennmaradó órák (ideális)" show_burndown_chart: "Napi teendő ábra" story: "Sztori" story_points: "Story pontok" @@ -141,18 +141,18 @@ hu: points_resolved: "points resolved" points_to_accept: "points not accepted" points_to_resolve: "points not resolved" - project_module_backlogs: "Backlogs" + project_module_backlogs: "Elvégzendő feladatok" rb_label_copy_tasks: "Copy work packages" rb_label_copy_tasks_all: "Mind" rb_label_copy_tasks_none: "None" rb_label_copy_tasks_open: "Open" rb_label_link_to_original: "Include link to original story" - remaining_hours: "hátralévő munka" + remaining_hours: "Fennmaradó órák" required_burn_rate_hours: "required burn rate (hours)" required_burn_rate_points: "required burn rate (points)" - todo_work_package_description: "%{summary}: %{url}\n%{description}" + todo_work_package_description: "%{összegzés}: %{url}\n%{leírás}" todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Column in backlog" + version_settings_display_label: "Hátralévő feladatok oszlopa" version_settings_display_option_left: "balra" version_settings_display_option_none: "none" version_settings_display_option_right: "jobbra" diff --git a/modules/backlogs/config/locales/crowdin/ms.yml b/modules/backlogs/config/locales/crowdin/ms.yml index 6e749610528d..2e7280ed6150 100644 --- a/modules/backlogs/config/locales/crowdin/ms.yml +++ b/modules/backlogs/config/locales/crowdin/ms.yml @@ -152,7 +152,7 @@ ms: required_burn_rate_points: "kadar pembakaran yang diperlukan (mata)" todo_work_package_description: "%{summary}: %{url}\n%{description}" todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Kolum dalam backlog" + version_settings_display_label: "Kolum dalam tunggakan" version_settings_display_option_left: "kiri" version_settings_display_option_none: "tiada" version_settings_display_option_right: "kanan" diff --git a/modules/bim/config/locales/crowdin/cs.seeders.yml b/modules/bim/config/locales/crowdin/cs.seeders.yml index 1c42c958c73f..c7e36b8c2348 100644 --- a/modules/bim/config/locales/crowdin/cs.seeders.yml +++ b/modules/bim/config/locales/crowdin/cs.seeders.yml @@ -578,10 +578,10 @@ cs: * ... item_3: subject: Odesílání modelu BIM - description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. + description: Tento typ je hierarchicky nadřazený typům "Clash" a "Request", představuje tedy obecnou poznámku. item_5: subject: Koordinace, první cyklus - description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. + description: Tento typ je hierarchicky nadřazeným typem "Clash" a "Request", proto představuje obecnou poznámku. children: item_0: subject: Koordinace různých modelů BIM diff --git a/modules/bim/config/locales/crowdin/js-ms.yml b/modules/bim/config/locales/crowdin/js-ms.yml index c667f3c0b691..24303eab2ae8 100644 --- a/modules/bim/config/locales/crowdin/js-ms.yml +++ b/modules/bim/config/locales/crowdin/js-ms.yml @@ -4,9 +4,9 @@ ms: bcf: label_bcf: 'BCF' import: 'Import' - import_bcf_xml_file: 'Import fail BCF XML (BCF version 2.1)' + import_bcf_xml_file: 'Import fail BCF XML (BCF versi 2.1)' export: 'Eksport' - export_bcf_xml_file: 'Eksport fail BCF XML (BCF version 2.1)' + export_bcf_xml_file: 'Eksport fail BCF XML (BCF versi 2.1)' viewpoint: 'Sudut pandangan' add_viewpoint: 'Tambah sudut pandangan' show_viewpoint: 'Tunjuk sudut pandangan' @@ -26,4 +26,4 @@ ms: split_cards: 'Pemerhati dan kad' revit: revit_add_in: "Revit Add-In" - revit_add_in_settings: "Revit Add-In settings" + revit_add_in_settings: "Seting Revit Add-In" diff --git a/modules/bim/config/locales/crowdin/ms.seeders.yml b/modules/bim/config/locales/crowdin/ms.seeders.yml index 95f86dffaca7..fd3a24e2e9c8 100644 --- a/modules/bim/config/locales/crowdin/ms.seeders.yml +++ b/modules/bim/config/locales/crowdin/ms.seeders.yml @@ -135,9 +135,9 @@ ms: 4. _Cipta dan kemaskini carta Gantt_: → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. 5. _Mengaktifkan modul lanjutan_: → Pergi ke [Seting projek → Modul]({{opSetting:base_url}}/projects/demo-construction-project/settings/modules). 6. _Lihat paparan til untuk dapatkan gambar keseluruhan isu BCF anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) - 7. _Pemangkin kerja? Semak board kami yang baru:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-construction-project/boards) + 7. _Pemangkin kerja? Semak board kami yang baru:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-construction-project/boards) - Disini anda akan jumpa [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Disini anda akan jumpa [Panduan Pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami jika anda mempunyai sebarang soalan atau memerlukan sokongan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -185,14 +185,14 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) dalam navigasi projek. - 2. _Lihat kerja dalam projek anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 3. _Cipta satu pakej kerja baharu:_ → Pergi ke [Work packages → Create]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 4. _Cipta dan kemaskini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi project. - 5. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). - 6. _Pemangkin kerja? Cipta satu panel baharu:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) dalam navigasi projek. + 2. _Lihat kerja dalam projek anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 3. _Cipta satu pakej kerja baru:_ → Pergi ke [Pakej kerja → Cipta]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 4. _Cipta dan kemaskini carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 5. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). + 6. _Pemangkin kerja? Cipta satu board baharu:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) - Di sini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Di sini anda akan menemui [Panduan Pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -209,7 +209,7 @@ ms: description: |- Permulaan projek menandakan permulaan projek di dalam syarikat anda. Setiap orang yang menjadi sebahagian daripada projek ini perlu dijemput untuk menyertai taklimat pertama projek. - Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Gantt chart]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). + Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Carta Gantt]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). item_1: subject: Penilaian asas description: Jenis ini adalah induk secara hierarki bagi jenis "Konflik" dan "Permintaan", oleh itu mewakili catatan umum. @@ -318,7 +318,7 @@ ms: ## Huraian * Menyediakan tapak untuk projek - * Kumpulkan pasukan + * Kumpulkan pasukan * ... item_1: subject: Asas @@ -388,7 +388,7 @@ ms: * Menyiapkan pemasangan sistem perkhidmatan bangunan * Menyiapkan pembinaan dalaman - * MEnyiapkan muka bangunan + * Menyiapkan muka bangunan * ... item_6: subject: Majlis perasmian rumah baru @@ -439,17 +439,17 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-bim-project/members) dalam navigasi projek. + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-bim-project/members) dalam navigasi projek. 2. _Muat naik dan paparkan model 3d dalam format IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) dalam navigasi projek. 3. _Cipta dan urus isu BCF yang berkaitan dalam model IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) → Cipta. - 4. _Lihat kerja dalam projek anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 5. _Cipta satu pakej pekerjaan baharu:_ → Pergi ke [Work packages → Create]({{opSetting:base_url}}/projects/demo-bim-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 6. _Cipta dan kemaskini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 7. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules]({{opSetting:base_url}}/projects/demo-bim-project/settings/modules). - 8. _Semak paparan til untuk mendapatkan gambaran keseluruhan mengenai isu-isu BCF anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) - 9. _Pemangkin kerja? Cipta satu panel baru:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-bim-project/boards) - - Di sini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + 4. _Lihat kerja dalam projek anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 5. _Cipta satu pakej pekerjaan baharu:_ → Pergi ke [Pakej kerja → Cipta]({{opSetting:base_url}}/projects/demo-bim-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 6. _Cipta dan kemaskini carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 7. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul]({{opSetting:base_url}}/projects/demo-bim-project/settings/modules). + 8. _Semak paparan til untuk mendapatkan gambaran keseluruhan mengenai isu-isu BCF anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) + 9. _Pemangkin kerja? Cipta satu panel baru:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-bim-project/boards) + + Di sini anda akan menemui [Panduan pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -466,7 +466,7 @@ ms: description: |- Permulaan projek menandakan permulaan projek di dalam syarikat anda. Setiap orang yang menjadi sebahagian daripada projek ini perlu dijemput untuk menyertai taklimat pertama projek. - Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Gantt chart]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). + Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Carta Gantt]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). item_1: subject: Penyediaan projek description: Jenis ini adalah induk bagi jenis "Clash" dan "Request", oleh itu mewakili catatan umum. @@ -715,16 +715,16 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-bcf-management-project/members?show_add_members=true) dalam navigasi projek. + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-bcf-management-project/members?show_add_members=true) dalam navigasi projek. 2. _Muat naik dan lihat model 3d dalam format IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) dalam navigasi projek. 3. _Cipta dan urus isu BCF yang dihubung secara langsung dalam model IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) → Cipta. 4. _Lihat fail BCF dalam projek anda:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22status%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) dalam navigasi projek. 5. _Muatkan fail BCF anda:_ → Pergi ke [BCF → Import.]({{opSetting:base_url}}/projects/demo-bcf-management-project/issues/upload) - 6. _Cipta dan kemas kini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22days%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D) dalam navigasi projek. - 7. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules.]({{opSetting:base_url}}/projects/demo-bcf-management-project/settings/modules) - 8. _Anda suka pendekatan yang pantas ini? Cipta Board:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-bcf-management-project/boards). + 6. _Cipta dan kemas kini Carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22days%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D) dalam navigasi projek. + 7. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul.]({{opSetting:base_url}}/projects/demo-bcf-management-project/settings/modules) + 8. _Anda suka pendekatan yang pantas ini? Cipta Board:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-bcf-management-project/boards). - Disini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Disini anda akan menemui [Panduan pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: diff --git a/modules/bim/config/locales/crowdin/ms.yml b/modules/bim/config/locales/crowdin/ms.yml index 014d2278eca5..b845326d6795 100644 --- a/modules/bim/config/locales/crowdin/ms.yml +++ b/modules/bim/config/locales/crowdin/ms.yml @@ -74,10 +74,10 @@ ms: oauth: scopes: bcf_v2_1: "Akses penuh untuk BCF v2.1 API" - bcf_v2_1_text: "Aplikasi akan terima akses baca & tulis penuh di OpenProject BCF API v2.1 untuk meaksanakan tindakan bagi pihak anda." + bcf_v2_1_text: "Aplikasi akan terima akses baca & tulis penuh di OpenProject BCF API v2.1 untuk melaksanakan tindakan bagi pihak anda." activerecord: models: - bim/ifc_models/ifc_model: "model IFC" + bim/ifc_models/ifc_model: "Model IFC" attributes: bim/ifc_models/ifc_model: ifc_attachment: "Fail IFC" @@ -88,7 +88,7 @@ ms: bim/ifc_models/ifc_model: attributes: base: - ifc_attachment_missing: "Tiada fail ifc yang dilampirkan" + ifc_attachment_missing: "Tiada fail IFC yang dilampirkan" invalid_ifc_file: "Fail yang ditetapkan adalah fail IFC yang tidak sah." bim/bcf/viewpoint: bitmaps_not_writable: "bitmaps tidak boleh ditulis kerana belum dilaksanakan." @@ -99,7 +99,7 @@ ms: invalid_orthogonal_camera: "kamera_serenjang adalah tidak sah." invalid_perspective_camera: "kamera_perspektif adalah tidak sah." mismatching_guid: "The guid in the json_viewpoint does not match the model's guid." - no_json: "Is not a well structured json." + no_json: "Bukan json yang tersusun dengan baik." snapshot_type_unsupported: "jenis_snapshot perlu sama ada 'png' atau 'jpg'." snapshot_data_blank: "data_snapshot perlu disediakan." unsupported_key: "Properti json yang tidak disokong disertakan." @@ -108,16 +108,16 @@ ms: ifc_models: label_ifc_models: 'Model IFC' label_new_ifc_model: 'Model IFC baharu' - label_show_defaults: 'Show defaults' + label_show_defaults: 'Tunjukkan defaults' label_default_ifc_models: 'Model default IFC ' label_edit_defaults: 'Edit defaults' no_defaults_warning: - title: 'No IFC model was set as default for this project.' + title: 'Tiada model IFC yang ditetapkan sebagai default untuk projek ini.' check_1: 'Semak bahawa anda telah memuat naik sekurang-kurangnya satu model IFC.' check_2: 'Semak bahawa salah satu model IFC ditetapkan sebagai "Default".' no_results: "Tiada model IFC yang telah dimuat naik di dalam projek ini." conversion_status: - label: 'Pemprosesan?' + label: 'Proses?' pending: 'Dalam proses' processing: 'Sedang di proses' completed: 'Selesai' @@ -127,7 +127,7 @@ ms: flash_messages: upload_successful: 'Muat naik berjaya. Akan diproses dan sedia untuk digunakan dalam masa beberapa minit.' conversion: - missing_commands: "Arahan penukar IFC berikut hilang pada sistem ini: %{names}" + missing_commands: "Arahan converter IFC berikut hilang dari sistem ini: %{names}" project_module_ifc_models: "Model IFC" permission_view_ifc_models: "Paparkan model IFC" permission_manage_ifc_models: "Import dan urus model IFC" diff --git a/modules/boards/config/locales/crowdin/js-ms.yml b/modules/boards/config/locales/crowdin/js-ms.yml index 46be2a3f4228..c1ac594abee6 100644 --- a/modules/boards/config/locales/crowdin/js-ms.yml +++ b/modules/boards/config/locales/crowdin/js-ms.yml @@ -42,15 +42,15 @@ ms: action_text: > Board dengan senarai yang disaring di atribut %{attribute}. Pemindahan pakej kerja ke senarai lain akan mengemas kini atribut mereka. action_text_subprojects: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + Board dengan kolum automatik untuk subprojek. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. action_text_subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + Board dengan kolum automatik untuk sub-elements. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. action_text_status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + Board gaya kanban asas dengan kolum untuk status seperti Untuk Dilakukan, Dalam Pelaksanaan, Selesai. action_text_assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + Board dengan kolum automatik berdasarkan pengguna yang ditentukan. Ideal untuk penghantaran pakej kerja. action_text_version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + Board dengan kolum automatik berdasarkan versi atribut. Ideal untuk merancang pembangunan produk. action_type: assignee: wakil status: status diff --git a/modules/boards/config/locales/crowdin/ms.yml b/modules/boards/config/locales/crowdin/ms.yml index ca810b0ecf2b..b2e8a041615e 100644 --- a/modules/boards/config/locales/crowdin/ms.yml +++ b/modules/boards/config/locales/crowdin/ms.yml @@ -4,16 +4,16 @@ ms: name: "OpenProject Boards" description: "Provides board views." permission_show_board_views: "Paparkan papan" - permission_manage_board_views: "Manage boards" - project_module_board_view: "Boards" + permission_manage_board_views: "Urus board" + project_module_board_view: "Board" boards: label_board: "Board" - label_boards: "Boards" - label_create_new_board: "Create new board" - label_board_type: "Board type" + label_boards: "Board" + label_create_new_board: "Cipta board baru" + label_board_type: "Jenis board" board_types: free: "Asas\n" - action: "Action board (%{attribute})" + action: "Board tindakan (%{attribute})" board_type_attributes: assignee: Wakil status: Status @@ -23,17 +23,17 @@ ms: basic: "Asas\n" board_type_descriptions: basic: > - Start from scratch with a blank board. Manually add cards and columns to this board. + Bermula dari awal dengan board kosong. Tambah kad dan kolum secara manual kepada board ini. status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + Board gaya kanban asas dengan kolum untuk status seperti Untuk Dilakukan, Dalam Pelaksanaan, Selesai. assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + Board dengan kolum automatik berdasarkan pengguna yang ditentukan. Ideal untuk penghantaran pakej kerja. version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + Board dengan kolum automatik berdasarkan versi atribut. Ideal untuk merancang pembangunan produk. subproject: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + Board dengan kolum automatik untuk subprojek. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + Board dengan kolum automatik untuk sub-elements. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. upsale: teaser_text: 'Would you like to automate your workflows with Boards? Advanced boards are an Enterprise add-on. Please upgrade to a paid plan.' upgrade: 'Naik taraf sekarang' diff --git a/modules/budgets/config/locales/crowdin/js-ms.yml b/modules/budgets/config/locales/crowdin/js-ms.yml index 8236365cf878..e9634ba74563 100644 --- a/modules/budgets/config/locales/crowdin/js-ms.yml +++ b/modules/budgets/config/locales/crowdin/js-ms.yml @@ -23,4 +23,4 @@ ms: js: work_packages: properties: - costObject: "Bajet" + costObject: "Anggaran" diff --git a/modules/budgets/config/locales/crowdin/ms.yml b/modules/budgets/config/locales/crowdin/ms.yml index 8fe78c4bb612..eb41079dc982 100644 --- a/modules/budgets/config/locales/crowdin/ms.yml +++ b/modules/budgets/config/locales/crowdin/ms.yml @@ -47,32 +47,32 @@ ms: attributes: budget: "Anggaran" button_add_budget_item: "Tambah kos yang dirancang" - button_add_budget: "Tambah bajet" + button_add_budget: "Tambah anggaran" button_add_cost_type: "Tambah jenis kos" - button_cancel_edit_budget: "Batalkan bajet penyuntingan" + button_cancel_edit_budget: "Batalkan anggaran penyuntingan" button_cancel_edit_costs: "Batalkan kos penyuntingan" caption_labor: "Buruh" caption_labor_costs: "Kos buruh sebenar" caption_material_costs: "Kos unit sebenar" - budgets_title: "Bajet" + budgets_title: "Anggaran" events: - budget: "Bajet yang diedit" - help_click_to_edit: "Klik sini untuk edit." + budget: "Anggaran yang diedit" + help_click_to_edit: "Klik di sini untuk mengedit" help_currency_format: "Format nilai mata wang yang dipaparkan. %n diganti dengan nilai mata wang, %u diganti dengan unit mata wang." help_override_rate: "Masukkan nilai disini untuk gantikan kadar default." - label_budget: "Bajet" - label_budget_new: "Bajet baru" - label_budget_plural: "Bajet" - label_budget_id: "Bajet #%{id}" - label_deliverable: "Bajet" + label_budget: "Anggaran" + label_budget_new: "Anggaran baru" + label_budget_plural: "Anggaran" + label_budget_id: "Anggaran #%{id}" + label_deliverable: "Anggaran" label_example_placeholder: 'e.g., %{decimal}' - label_view_all_budgets: "Paparkan semua bajet" + label_view_all_budgets: "Paparkan semua anggaran" label_yes: "Ya" notice_budget_conflict: "Work packages must be of the same project." - notice_no_budgets_available: "Tiada bajet available." + notice_no_budgets_available: "Tiada bajet yang tersedia." permission_edit_budgets: "Edit bajet" - permission_view_budgets: "Paparkan bajet" - project_module_budgets: "Bajet" - text_budget_reassign_to: "Pindahkan mereka ke bajet ini" - text_budget_delete: "Padam bajet dari semua pakej kerja" - text_budget_destroy_assigned_wp: "Terdapat %{count} pakej kerja ditugaskan untuk bajet ini. Apa yang anda ingin lakukan?" + permission_view_budgets: "Paparkan anggaran" + project_module_budgets: "Anggaran" + text_budget_reassign_to: "Pindahkan mereka ke anggaran ini:" + text_budget_delete: "Padam anggaran dari semua pakej kerja" + text_budget_destroy_assigned_wp: "Terdapat %{count} pakej kerja ditugaskan untuk anggaran ini. Apa yang anda ingin lakukan?" diff --git a/modules/calendar/config/locales/crowdin/hu.yml b/modules/calendar/config/locales/crowdin/hu.yml index e63bc3b355ab..b6e857068d17 100644 --- a/modules/calendar/config/locales/crowdin/hu.yml +++ b/modules/calendar/config/locales/crowdin/hu.yml @@ -4,9 +4,9 @@ hu: name: "OpenProject Calendar" description: "Provides calendar views." label_calendar: "Naptár" - label_calendar_plural: "Naptárak" - label_new_calendar: "Új naptár" - permission_view_calendar: "Naptárak megtekintése" - permission_manage_calendars: "Naptárak kezelése" + label_calendar_plural: "Naptár" + label_new_calendar: "Új esemény" + permission_view_calendar: "Naptár bejegyzések megtekintése" + permission_manage_calendars: "Naptár kezelés" permission_share_calendars: "Feliratkozás az iCalendars-ra" project_module_calendar_view: "Naptárak" diff --git a/modules/calendar/config/locales/crowdin/js-hu.yml b/modules/calendar/config/locales/crowdin/js-hu.yml index 4970b7e3381a..73cf2edc1572 100644 --- a/modules/calendar/config/locales/crowdin/js-hu.yml +++ b/modules/calendar/config/locales/crowdin/js-hu.yml @@ -4,5 +4,5 @@ hu: calendar: create_new: 'Új naptár létrehozása' title: 'Naptár' - too_many: 'Összesen %{count} munkacsomag van, de csak %{max} jeleníthető meg.' + too_many: 'Összesen %{százalék_szám} munkacsomag van, de csak %{maximum} jeleníthető meg.' unsaved_title: 'Névtelen naptár' diff --git a/modules/costs/config/locales/crowdin/hu.yml b/modules/costs/config/locales/crowdin/hu.yml index 72ac4c3d60be..7801700ffd6b 100644 --- a/modules/costs/config/locales/crowdin/hu.yml +++ b/modules/costs/config/locales/crowdin/hu.yml @@ -34,7 +34,7 @@ hu: unit: "Egység neve" unit_plural: "Többes számú egység neve" work_package: - costs_by_type: "Elköltött egység" + costs_by_type: "Elhasznált egységek" labor_costs: "Munkaerő költségek" material_costs: "Egység költségek" overall_costs: "Összes költség" @@ -104,7 +104,7 @@ hu: label_work_package_filter_add: "Munkacsomag szűrő hozzáadása" label_kind: "Típus" label_less_or_equal: "<=" - label_log_costs: "Költségek naplózása" + label_log_costs: "Naplózott költségek" label_no: "Nem" label_option_plural: "Beállítások" label_overall_costs: "Összes költség" diff --git a/modules/gitlab_integration/config/locales/crowdin/cs.yml b/modules/gitlab_integration/config/locales/crowdin/cs.yml index becd05dc9d7a..e98796a3fe4a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/cs.yml +++ b/modules/gitlab_integration/config/locales/crowdin/cs.yml @@ -40,7 +40,7 @@ cs: merge_request_closed_comment: > **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR sloučeno:** Požadavek na sloučení %{mr_number} [%{mr_title}](%{mr_url}) pro [%{repository}](%{repository_url}) byl sloučen [%{gitlab_user}](%{gitlab_user_url}). merge_request_reopened_comment: > **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). note_commit_referenced_comment: > diff --git a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml index f01e53cdbfc6..f90aa30e9a4a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml @@ -33,18 +33,18 @@ pt-PT: label: Criar MR description: Crie Pedido de Fusão copy_menu: - label: Git snippets + label: Fragmentos de código Git description: Copiar pedaços de código git para área de transferência git_actions: branch_name: Nome do ramo - commit_message: Commit message + commit_message: Mensagem de confirmação cmd: Criar ramificação com a confirmação vazia - title: Quick snippets for Git + title: Fragmentos de código rápido para Git copy_success: '✅ Copiado!' copy_error: '❌ A cópia falhou!' tab_issue: - empty: 'There are no issues linked yet. Link an existing issue by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the issue title/description or create a new issue.' + empty: 'Ainda não existem problemas associados. Associe um problema existente utilizando o código OP#%{wp_id} (ou PP#%{wp_id} para ligações privadas) no título/descrição do problema ou crie um problema novo.' tab_mrs: - empty: 'There are no merge requests linked yet. Link an existing MR by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the MR title/description or create a new MR.' + empty: 'Ainda não existem pedidos de fusão associados. Associe um MR existente utilizando o código OP#%{wp_id} (ou PP#%{wp_id} para ligações privadas) no título/descrição do MR ou crie um novo MR.' gitlab_pipelines: Pipelines updated_on: Atualizado em diff --git a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml index 2281be87d617..8cfdaaaa5baf 100644 --- a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml @@ -33,35 +33,35 @@ pt-PT: labels: invalid_schema: "deve ser um conjunto de hashes com as chaves: cor, título" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "Mostrar conteúdo GitLab" gitlab_integration: merge_request_opened_comment: > - **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **MR aberto:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi aberto por [%{gitlab_user}](%{gitlab_user_url}). merge_request_closed_comment: > - **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **MR fechado:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi fechado por [%{gitlab_user}](%{gitlab_user_url}). merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR fundido:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi fundido por [%{gitlab_user}](%{gitlab_user_url}). merge_request_reopened_comment: > - **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **MR reaberto:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi reaberto por [%{gitlab_user}](%{gitlab_user_url}). note_commit_referenced_comment: > - **Referenced in Commit:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in a Commit Note [%{commit_id}](%{commit_url}) on [%{repository}](%{repository_url}): %{commit_note} + **Referenciado em Confirmação:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP numa Nota de Confirmação [%{commit_id}](%{commit_url}) em [%{repository}](%{repository_url}): %{commit_note} note_mr_referenced_comment: > - **Referenced in MR:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Referenciado em MR:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Pedido de Fusão %{mr_number} [%{mr_title}](%{mr_url}) em [%{repository}](%{repository_url}): %{mr_note} note_mr_commented_comment: > - **Commented in MR:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Comentado em RM:** [%{gitlab_user}](%{gitlab_user_url}) comentou este WP no Pedido de Fusão %{mr_number} [%{mr_title}](%{mr_url}) em [%{repository}](%{repository_url}): %{mr_note} note_issue_referenced_comment: > - **Referenced in Issue:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Referenciado no Problema:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Problema %{issue_number} [%{issue_title}](%{issue_url}) em [%{repository}](%{repository_url}): %{issue_note} note_issue_commented_comment: > - **Commented in Issue:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Comentado no Problema:** [%{gitlab_user}](%{gitlab_user_url}) comentou este WP no Problema %{issue_number} [%{issue_title}](%{issue_url}) em [%{repository}](%{repository_url}): %{issue_note} note_snippet_referenced_comment: > - **Referenced in Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) on [%{repository}](%{repository_url}): %{snippet_note} + **Referenciado no Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) em [%{repository}](%{repository_url}): %{snippet_note} issue_opened_referenced_comment: > - **Issue Opened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Aberto:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi aberto por [%{gitlab_user}](%{gitlab_user_url}). issue_closed_referenced_comment: > - **Issue Closed:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Fechado:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi fechado por [%{gitlab_user}](%{gitlab_user_url}). issue_reopened_referenced_comment: > - **Issue Reopened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Reaberto:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi reaberto por [%{gitlab_user}](%{gitlab_user_url}). push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Enviado no MR:** [%{gitlab_user}](%{gitlab_user_url}) enviou [%{commit_number}](%{commit_url}) para [%{repository}](%{repository_url}) em %{commit_timestamp}: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Enviado no MR:** [%{gitlab_user}](%{gitlab_user_url}) enviou vários compromissos [%{commit_number}](%{commit_url}) para [%{repository}](%{repository_url}) em %{commit_timestamp}: %{commit_note} diff --git a/modules/grids/config/locales/crowdin/js-pt-PT.yml b/modules/grids/config/locales/crowdin/js-pt-PT.yml index 468f88f1cc3f..470c6a6212a3 100644 --- a/modules/grids/config/locales/crowdin/js-pt-PT.yml +++ b/modules/grids/config/locales/crowdin/js-pt-PT.yml @@ -8,7 +8,7 @@ pt-PT: text: "Alguns widgets, como o widget gráfico do pacote de trabalho, estão disponíveis apenas na edição Enterprise." link: 'Edição Enterprise.' widgets: - missing_permission: "You don't have the necessary permissions to view this widget." + missing_permission: "Não tem as permissões necessárias para visualizar este widget." custom_text: title: 'Texto personalizado' documents: diff --git a/modules/ldap_groups/config/locales/crowdin/ms.yml b/modules/ldap_groups/config/locales/crowdin/ms.yml index 0d13fb571b4c..e24a0ebb4176 100644 --- a/modules/ldap_groups/config/locales/crowdin/ms.yml +++ b/modules/ldap_groups/config/locales/crowdin/ms.yml @@ -51,8 +51,8 @@ ms: destroy: title: 'Keluarkan kumpulan yang diselaraskan %{name}' confirmation: "Jika anda teruskan, kumpulan yang diselaraskan %{name} dan semua pengguna %{users_count} yang diselaraskan melalui itu akan dikeluarkan." - info: "Note: The OpenProject group itself and members added outside this LDAP synchronization will not be removed." - verification: "Enter the group's name %{name} to verify the deletion." + info: "Perhatian: Kumpulan OpenProject itu sendiri dan ahli yang ditambah di luar penjanaan LDAP tidak akan dikeluarkan." + verification: "Masukkan nama kumpulan %{name} untuk mengesahkan pembuangan." help_text_html: | This module allows you to set up a synchronization between LDAP and OpenProject groups. It depends on LDAP groups need to use the groupOfNames / memberOf attribute set to be working with OpenProject. diff --git a/modules/meeting/config/locales/crowdin/js-ms.yml b/modules/meeting/config/locales/crowdin/js-ms.yml index a633717befab..d209653a73bb 100644 --- a/modules/meeting/config/locales/crowdin/js-ms.yml +++ b/modules/meeting/config/locales/crowdin/js-ms.yml @@ -21,4 +21,4 @@ #++ ms: js: - label_meetings: 'Meetings' + label_meetings: 'Mesyuarat' diff --git a/modules/meeting/config/locales/crowdin/ms.seeders.yml b/modules/meeting/config/locales/crowdin/ms.seeders.yml index 595a75234889..ffdf184cb36f 100644 --- a/modules/meeting/config/locales/crowdin/ms.seeders.yml +++ b/modules/meeting/config/locales/crowdin/ms.seeders.yml @@ -9,21 +9,21 @@ ms: demo-project: meetings: item_0: - title: Weekly + title: Mingguan meeting_agenda_items: item_0: - title: Good news + title: Berita baik item_1: - title: Updates from development team + title: Kemas kini dari pasukan pembangunan item_2: - title: Updates from product team + title: Kemas kini dari pasukan produk item_3: - title: Updates from marketing team + title: Kemas kini dari pasukan pemasaran item_4: - title: Updates from sales team + title: Kemas kini dari pasukan jualan item_5: - title: Review of quarterly goals + title: Penilaian matlamat suku tahunan item_6: - title: Core values feedback + title: Maklum balas nilai teras item_7: - title: General topics + title: Topik umum diff --git a/modules/meeting/config/locales/crowdin/ms.yml b/modules/meeting/config/locales/crowdin/ms.yml index a6f52fc90ea7..9ad233d4322b 100644 --- a/modules/meeting/config/locales/crowdin/ms.yml +++ b/modules/meeting/config/locales/crowdin/ms.yml @@ -28,156 +28,156 @@ ms: activerecord: attributes: meeting: - type: "Meeting type" - location: "Location" - duration: "Duration" - notes: "Notes" - participants: "Participants" + type: "Jenis mesyuarat" + location: "Lokasi" + duration: "Tempoh" + notes: "Nota" + participants: "Peserta" participant: - other: "%{count} Participants" - participants_attended: "Attendees" - participants_invited: "Invitees" - project: "Project" - start_date: "Date" - start_time: "Time" - start_time_hour: "Starting time" + other: "1 Peserta" + participants_attended: "Peserta" + participants_invited: "Jemputan" + project: "Projek" + start_date: "Tarikh" + start_time: "Masa" + start_time_hour: "Masa mula" meeting_agenda_items: - title: "Title" - author: "Responsible" - duration_in_minutes: "Duration (min)" - description: "Notes" + title: "Tajuk" + author: "Bertanggungjawab" + duration_in_minutes: "Tempoh (minit)" + description: "Nota" errors: messages: - invalid_time_format: "is not a valid time. Required format: HH:MM" + invalid_time_format: "bukan masa yang sah. Format yang diperlukan: JJ:MM" models: - structured_meeting: "Meeting (dynamic)" - meeting_agenda_item: "Agenda item" + structured_meeting: "Mesyuarat (dinamik)" + meeting_agenda_item: "Item agenda" meeting_agenda: "Agenda" - meeting_minutes: "Minutes" + meeting_minutes: "Minit mesyuarat" activity: filter: - meeting: "Meetings" - description_attended: "attended" - description_invite: "invited" + meeting: "Mesyuarat" + description_attended: "dihadiri" + description_invite: "dijemput" events: - meeting: Meeting edited - meeting_agenda: Meeting agenda edited - meeting_agenda_closed: Meeting agenda closed - meeting_agenda_opened: Meeting agenda opened - meeting_minutes: Meeting minutes edited - meeting_minutes_created: Meeting minutes created - error_notification_with_errors: "Failed to send notification. The following recipients could not be notified: %{recipients}" - label_meeting: "Meeting" - label_meeting_plural: "Meetings" - label_meeting_new: "New Meeting" - label_meeting_edit: "Edit Meeting" + meeting: Mesyuarat telah diedit + meeting_agenda: Agenda mesyuarat yang diedit + meeting_agenda_closed: Agenda mesyuarat ditutup + meeting_agenda_opened: Agenda mesyuarat dibuka + meeting_minutes: Minit mesyuarat diedit + meeting_minutes_created: Minit mesyuarat dicipta + error_notification_with_errors: "Gagal untuk hantar pemberitahuan. Penerima berikut tidak dapat diberitahu: %{recipients}" + label_meeting: "Mesyuarat" + label_meeting_plural: "Mesyuarat" + label_meeting_new: "Mesyuarat Baharu" + label_meeting_edit: "Edit mesyuarat" label_meeting_agenda: "Agenda" - label_meeting_minutes: "Minutes" - label_meeting_close: "Close" - label_meeting_open: "Open" - label_meeting_agenda_close: "Close the agenda to begin the Minutes" - label_meeting_date_time: "Date/Time" - label_meeting_diff: "Diff" - label_upcoming_meetings: "Upcoming meetings" - label_past_meetings: "Past meetings" - label_upcoming_meetings_short: "Upcoming" - label_past_meetings_short: "Past" - label_involvement: "Involvement" - label_upcoming_invitations: "Upcoming invitations" - label_past_invitations: "Past invitations" - label_attendee: "Attendee" - label_author: "Creator" - label_notify: "Send for review" - label_icalendar: "Send iCalendar" - label_icalendar_download: "Download iCalendar event" + label_meeting_minutes: "Minit" + label_meeting_close: "Tutup" + label_meeting_open: "Buka" + label_meeting_agenda_close: "Tutup agenda untuk mulakan minit" + label_meeting_date_time: "Tarikh/Masa" + label_meeting_diff: "Beza" + label_upcoming_meetings: "Mesyuarat akan datang" + label_past_meetings: "Mesyuarat lalu" + label_upcoming_meetings_short: "Akan Datang" + label_past_meetings_short: "Lalu" + label_involvement: "Penglibatan" + label_upcoming_invitations: "Jemputan akan datang" + label_past_invitations: "Jemputan lalu" + label_attendee: "Peserta" + label_author: "Pencipta" + label_notify: "Hantar untuk semakan" + label_icalendar: "Hantar iCalendar\n" + label_icalendar_download: "Muat turun acara dalam iCalendar" label_version: "Versi" - label_time_zone: "Time zone" - label_start_date: "Start date" + label_time_zone: "Zon waktu" + label_start_date: "Tarikh mula" meeting: copy: - title: "Copy meeting %{title}" - agenda: "Copy agenda" - agenda_text: "Copy the agenda of the old meeting" + title: "Salin mesyuarat %{title}" + agenda: "Salin agenda" + agenda_text: "Salin agenda mesyuarat lama" email: - open_meeting_link: "Open meeting" + open_meeting_link: "Buka mesyuarat" invited: - summary: "%{actor} has sent you an invitation for the meeting %{title}" + summary: "%{actor} menghantar anda jemputan untuk mesyuarat %{title}" rescheduled: header: "Meeting %{title} has been rescheduled" - summary: "Meeting %{title} has been rescheduled by %{actor}" - body: "The meeting %{title} has been rescheduled by %{actor}." - old_date_time: "Old date/time" - new_date_time: "New date/time" - label_mail_all_participants: "Send email to all participants" + summary: "Mesyuarat %{title} telah dijadual semula oleh %{actor}" + body: "Mesyuarat %{title} telah dijadual semula oleh %{actor}" + old_date_time: "Tarikh/masa lama" + new_date_time: "Tarikh/masa baru" + label_mail_all_participants: "Hantar emel ke semua peserta" types: - classic: 'Classic' - classic_text: 'Organize your meeting in a formattable text agenda and protocol.' - structured: 'Dynamic' - structured_text: 'Organize your meeting as a list of agenda items, optionally linking them to a work package.' - structured_text_copy: 'Copying a meeting will currently not copy the associated meeting agenda items, just the details' - copied: "Copied from Meeting #%{id}" - notice_successful_notification: "Notification sent successfully" - notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. - permission_create_meetings: "Create meetings" - permission_edit_meetings: "Edit meetings" - permission_delete_meetings: "Delete meetings" - permission_view_meetings: "View meetings" - permission_create_meeting_agendas: "Create meeting agendas" - permission_create_meeting_agendas_explanation: "Allows editing the Classic Meeting's agenda content." - permission_manage_agendas: "Manage agendas" - permission_manage_agendas_explanation: "Allows managing the Dynamic Meeting's agenda items." - permission_close_meeting_agendas: "Close agendas" - permission_send_meeting_agendas_notification: "Send review notification for agendas" - permission_create_meeting_minutes: "Manage minutes" - permission_send_meeting_minutes_notification: "Send review notification for minutes" - permission_meetings_send_invite: "Invite users to meetings" - permission_send_meeting_agendas_icalendar: "Send meeting agenda as calendar entry" - project_module_meetings: "Meetings" - text_duration_in_hours: "Duration in hours" - text_in_hours: "in hours" - text_meeting_agenda_for_meeting: 'agenda for the meeting "%{meeting}"' - text_meeting_closing_are_you_sure: "Are you sure you want to close the meeting agenda?" - text_meeting_agenda_open_are_you_sure: "This will overwrite all changes in the minutes! Do you want to continue?" - text_meeting_minutes_for_meeting: 'minutes for the meeting "%{meeting}"' - text_notificiation_invited: "This mail contains an ics entry for the meeting below:" - text_meeting_empty_heading: "Your meeting is empty" - text_meeting_empty_description_1: "Start by adding agenda items below. Each item can be as simple as just a title, but you can also add additional details like duration and notes." - text_meeting_empty_description_2: "You can also add references to existing work packages. When you do, related notes will automatically be visible in the work package's \"Meetings\" tab." - label_meeting_empty_action: "Add agenda item" - label_meeting_actions: "Meeting actions" - label_meeting_edit_title: "Edit meeting title" - label_meeting_delete: "Delete meeting" - label_meeting_created_by: "Created by" - label_meeting_last_updated: "Last updated" - label_agenda_item_undisclosed_wp: "Work package #%{id} not visible" - label_agenda_item_deleted_wp: "Deleted work package reference" - label_agenda_item_actions: "Agenda items actions" - label_agenda_item_move_to_top: "Move to top" - label_agenda_item_move_to_bottom: "Move to bottom" - label_agenda_item_move_up: "Move up" - label_agenda_item_move_down: "Move down" - label_agenda_item_add_notes: "Add notes" - label_meeting_details: "Meeting details" - label_meeting_details_edit: "Edit meeting details" - label_meeting_state_open: "Open" - label_meeting_state_closed: "Closed" - label_meeting_reopen_action: "Reopen meeting" - label_meeting_close_action: "Close meeting" - text_meeting_open_description: "This meeting is open. You can add/remove agenda items and edit them as you please. After the meeting is over, close it to lock it." - text_meeting_closed_description: "This meeting is closed. You cannot add/remove agenda items anymore." - label_meeting_manage_participants: "Manage participants" - label_meeting_no_participants: "No participants" - label_meeting_show_hide_participants: "Show/hide %{count} more" - label_meeting_show_all_participants: "Show all" - label_meeting_add_participants: "Add participants" - text_meeting_not_editable_anymore: "This meeting is not editable anymore." - text_meeting_not_present_anymore: "This meeting was deleted. Please select another meeting." - label_add_work_package_to_meeting_dialog_title: "Add work package to meeting" - label_add_work_package_to_meeting_dialog_button: "Add to meeting" + classic: 'Klasik' + classic_text: 'Susun mesyuarat anda dalam agenda teks boleh format dan protokol.' + structured: 'Dinamik' + structured_text: 'Susun mesyuarat anda sebagai senarai butiran agenda, secara pilihan menghubungnya kepada pakej kerja.' + structured_text_copy: 'Menyalin mesyuarat pada masa ini tidak akan menyalin item agenda mesyuarat yang berkaitan, hanya butiran sahaja' + copied: "Salin dari mesyuarat #%{id}" + notice_successful_notification: "Pemberitahuan berjaya dihantar" + notice_timezone_missing: Tiada zon waktu yang ditetapkan dan %{zone} adalah andaian.Untuk pilih zon waktu anda, sila klik sini. + permission_create_meetings: "Cipta mesyuarat" + permission_edit_meetings: "Edit mesyuarat" + permission_delete_meetings: "Hapuskan Mesyuarat" + permission_view_meetings: "Lihat Mesyuarat" + permission_create_meeting_agendas: "Cipta agenda mesyuarat" + permission_create_meeting_agendas_explanation: "Benarkan kandungan agenda Mesyuarat Klasik untuk diedit" + permission_manage_agendas: "Urus agenda" + permission_manage_agendas_explanation: "Benarkan pengurusan item agenda Dinamik Mesyuarat." + permission_close_meeting_agendas: "Tutup agenda" + permission_send_meeting_agendas_notification: "Hantar pemberitahuan semakan untuk agenda" + permission_create_meeting_minutes: "Urus minit mesyuarat" + permission_send_meeting_minutes_notification: "Hantar pemberitahuan semakan untuk minit mesyuarat" + permission_meetings_send_invite: "Jemput pengguna ke mesyuarat" + permission_send_meeting_agendas_icalendar: "Hantar agenda mesyuarat sebagai kemasukan kalendar" + project_module_meetings: "Mesyuarat" + text_duration_in_hours: "Jangka masa dalam jam" + text_in_hours: "dalam jam" + text_meeting_agenda_for_meeting: 'agenda untuk mesyuarat "%{meeting}"' + text_meeting_closing_are_you_sure: "Adakah anda pasti anda ingin menutup agenda mesyuarat?" + text_meeting_agenda_open_are_you_sure: "Ini akan menggantikan semua perubahan dalam minit mesyuarat! Adakah anda ingin teruskan?" + text_meeting_minutes_for_meeting: 'minit untuk mesyuarat "%{meeting}"' + text_notificiation_invited: "Mel ini mengandungi kemasukan ics untuk mesyuarat dibawah:" + text_meeting_empty_heading: "Mesyuarat anda kosong" + text_meeting_empty_description_1: "Mula dengan menambah item agenda dibawah. Setiap item boleh jadi seringkas tajuk, tapi anda juga boleh tambah butiran tambahan seperti jangka masa dan nota." + text_meeting_empty_description_2: "Anda juga boleh menambah rujukan ke pakej kerja yang sedia ada. Apabila anda lakukan, nota berkaitan secara automatik akan boleh dilihat dalam tab \"Mesyuarat\" pakej kerja." + label_meeting_empty_action: "Tambah item agenda" + label_meeting_actions: "Tindakan mesyuarat" + label_meeting_edit_title: "Edit tajuk mesyuarat" + label_meeting_delete: "Hapuskan mesyuarat" + label_meeting_created_by: "Dicipta oleh" + label_meeting_last_updated: "Kemas kini terakhir" + label_agenda_item_undisclosed_wp: "Pakej kerja #%{id} tidak kelihatan" + label_agenda_item_deleted_wp: "Hapuskan rujukan pakej kerja" + label_agenda_item_actions: "Tindakan item agenda" + label_agenda_item_move_to_top: "Alih ke paling atas" + label_agenda_item_move_to_bottom: "Alih ke paling bawah" + label_agenda_item_move_up: "Alihkan ke atas" + label_agenda_item_move_down: "Gerak ke bawah" + label_agenda_item_add_notes: "Tambah nota" + label_meeting_details: "Butiran mesyuarat" + label_meeting_details_edit: "Edit butiran mesyuarat" + label_meeting_state_open: "Buka" + label_meeting_state_closed: "Ditutup" + label_meeting_reopen_action: "Buka semula mesyuarat" + label_meeting_close_action: "Tutup mesyuarat" + text_meeting_open_description: "Mesyuarat ini terbuka. Anda boleh tambah/keluarkan item agenda dan edit mereka sesuka hati. Setelah mesyuarat berakhir, tutup mesyuarat untuk kunci." + text_meeting_closed_description: "Mesyuarat ini ditutup. Anda tidak boleh tambah/keluarkan item agenda lagi." + label_meeting_manage_participants: "Urus peserta" + label_meeting_no_participants: "Tiada peserta" + label_meeting_show_hide_participants: "Tunjuk/hilangkan %{count} lagi" + label_meeting_show_all_participants: "Tunjukkan semua" + label_meeting_add_participants: "Tambah peserta" + text_meeting_not_editable_anymore: "Mesyuarat ini tidak boleh diedit lagi." + text_meeting_not_present_anymore: "Mesyuarat ini telah dihapuskan. Sila pilih mesyuarat lain." + label_add_work_package_to_meeting_dialog_title: "Tambah pakej kerja ke mesyuarat" + label_add_work_package_to_meeting_dialog_button: "Tambah ke mesyuarat" label_meeting_selection_caption: "It's only possible to add this work package to upcoming or ongoing open meetings." - text_add_work_package_to_meeting_description: "A work package can be added to one or multiple meetings for discussion. Any notes concerning it are also visible here." - text_agenda_item_no_notes: "No notes provided" - text_agenda_item_not_editable_anymore: "This agenda item is not editable anymore." - text_work_package_has_no_upcoming_meeting_agenda_items: "This work package is not scheduled in an upcoming meeting agenda yet." - text_work_package_add_to_meeting_hint: "Use the \"Add to meeting\" button to add this work package to an upcoming meeting." - text_work_package_has_no_past_meeting_agenda_items: "This work package was not mentioned in a past meeting." + text_add_work_package_to_meeting_description: "Pakej kerja boleh ditambah kepada satu atau beberapa mesyuarat untuk perbincangan. Sebarang nota berkaitan juga kelihatan di sini." + text_agenda_item_no_notes: "Tiada nota disediakan" + text_agenda_item_not_editable_anymore: "Item agenda tidak boleh diedit lagi." + text_work_package_has_no_upcoming_meeting_agenda_items: "Pakej kerja ini belum dijadualkan dalam mana-mana agenda mesyuarat akan datang lagi." + text_work_package_add_to_meeting_hint: "Guna butang \"Tambah ke mesyuarat\" untuk tambah pakej kerja ini ke mesyuarat akan datang." + text_work_package_has_no_past_meeting_agenda_items: "Pakej kerja ini masih belum diutarakan dalam mesyuarat yang lalu." diff --git a/modules/my_page/config/locales/crowdin/js-ms.yml b/modules/my_page/config/locales/crowdin/js-ms.yml index 80596bfff1f9..d831c4788ca9 100644 --- a/modules/my_page/config/locales/crowdin/js-ms.yml +++ b/modules/my_page/config/locales/crowdin/js-ms.yml @@ -1,4 +1,4 @@ ms: js: my_page: - label: "My page" + label: "Halaman saya" diff --git a/modules/openid_connect/config/locales/crowdin/ms.yml b/modules/openid_connect/config/locales/crowdin/ms.yml index b9415982cf55..54b0a4a5da91 100644 --- a/modules/openid_connect/config/locales/crowdin/ms.yml +++ b/modules/openid_connect/config/locales/crowdin/ms.yml @@ -3,15 +3,15 @@ ms: name: "OpenProject OpenID Connect" description: "Adds OmniAuth OpenID Connect strategy providers to Openproject." logout_warning: > - You have been logged out. The contents of any form you submit may be lost. Please [log in]. + Anda telah log keluar. Apa-apa bentuk kandungan yang anda hantar mungkin hilang. Sila [log masuk]. activemodel: attributes: openid_connect/provider: - name: Name - display_name: Display name + name: Nama + display_name: Nama paparan identifier: Identifier - secret: Secret - scope: Scope + secret: "Sulit\n" + scope: Skop limit_self_registration: Limit self registration openid_connect: menu_title: OpenID providers diff --git a/modules/storages/config/locales/crowdin/cs.yml b/modules/storages/config/locales/crowdin/cs.yml index efe2768f6411..5a466dc9f299 100644 --- a/modules/storages/config/locales/crowdin/cs.yml +++ b/modules/storages/config/locales/crowdin/cs.yml @@ -63,7 +63,7 @@ cs: one_drive: Povolit OpenProject přístup k Azure datům pomocí OAuth pro připojení OneDrive/Sharepoint. redirect_uri_incomplete: one_drive: Dokončete nastavení správným přesměrováním URI. - confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? + confirm_replace_oauth_application: Tato akce obnoví aktuální OAuth přihlašovací údaje. Po potvrzení budete muset znovu zadat přihlašovací údaje u poskytovatele úložiště a všichni vzdálení uživatelé budou muset znovu autorizovat proti OpenProject Jste si jisti, že chcete pokračovat? confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? delete_warning: input_delete_confirmation: Zadejte název úložiště souboru %{file_storage} pro potvrzení odstranění. diff --git a/modules/storages/config/locales/crowdin/pt-PT.yml b/modules/storages/config/locales/crowdin/pt-PT.yml index 791b6d5cb368..02ad74fc47f9 100644 --- a/modules/storages/config/locales/crowdin/pt-PT.yml +++ b/modules/storages/config/locales/crowdin/pt-PT.yml @@ -53,8 +53,8 @@ pt-PT: complete_without_setup: Concluir sem isso done_complete_setup: Concluído, terminar configuração done_continue: Concluído, continuar - replace_oauth_application: Replace OpenProject OAuth - replace_oauth_client: Replace %{provider_type} OAuth + replace_oauth_application: Substituir OpenProject OAuth + replace_oauth_client: Substituir OAuth %{provider_type} save_and_continue: Guardar e continuar select_folder: Selecionar pasta configuration_checks: @@ -63,8 +63,8 @@ pt-PT: one_drive: Permitir que o OpenProject aceda aos dados do Azure utilizando o OAuth para ligar o OneDrive/Sharepoint. redirect_uri_incomplete: one_drive: Conclua a configuração com o redirecionamento correto da URI. - confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? - confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? + confirm_replace_oauth_application: Esta ação irá repor as credenciais OAuth atuais. Após a confirmação, terá de voltar a introduzir as credenciais no fornecedor de armazenamento e todos os utilizadores remotos terão de autorizar novamente o OpenProject. Tem a certeza de que pretende continuar? + confirm_replace_oauth_client: Esta ação irá repor as credenciais OAuth atuais. Depois de confirmar, terá de introduzir novas credenciais do fornecedor de armazenamento e todos os utilizadores terão de autorizar novamente em %{provider_type}. Tem a certeza de que pretende continuar? delete_warning: input_delete_confirmation: Introduza o nome do ficheiro de armazenamento %{file_storage} para confirmar a eliminação. irreversible_notice: A eliminação de um ficheiro de armazenamento é uma ação irreversível. diff --git a/modules/two_factor_authentication/config/locales/crowdin/cs.yml b/modules/two_factor_authentication/config/locales/crowdin/cs.yml index 0c74cb1f2ef9..0d4321082095 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/cs.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/cs.yml @@ -114,11 +114,11 @@ cs: is_default_cannot_delete: "Zařízení je označeno jako výchozí a nemůže být odstraněno z důvodu aktivní bezpečnostní politiky. Před smazáním označte jiné zařízení jako výchozí." not_existing: "Žádné 2FA zařízení nebylo zaregistrováno pro váš účet." 2fa_from_input: Zadejte prosím kód z Vašeho %{device_name} pro ověření Vaší identity. - 2fa_from_webauthn: Please provide the WebAuthn device %{device_name}. If it is USB based make sure to plug it in and touch it. Then click the sign in button. + 2fa_from_webauthn: Uveďte prosím zařízení WebAuthn %{device_name}. Pokud je založeno na USB, nezapomeňte jej připojit a dotknout se jej. Poté klikněte na tlačítko Přihlásit se. webauthn: title: "WebAuthn" description: Use Web Authentication to register a FIDO2 device (like a YubiKey) or the secure enclave of your mobile device as a second factor. - further_steps: After you have chosen a name, you can click the Continue button. Your browser will prompt you to present your WebAuthn device. When you have done so, you are done registering the device. + further_steps: Po zvolení jména můžete kliknout na tlačítko Pokračovat. Váš prohlížeč vás vyzve, abyste prezentovali vaše WebAuthn zařízení. Až tak učiníte, jste zařízení zaregistrovali. totp: title: "Použít autentifikátor založený na aplikaci" provisioning_uri: "Poskytování URI" diff --git a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml index 713fd96f3f0b..0d0e7ba01d25 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml @@ -76,7 +76,7 @@ pt-PT: button_register_mobile_phone_for_user: "Registre o celular" text_2fa_enabled: "Em cada login, este utilizador será solicitado a inserir um token OTP do seu dispositivo padrão 2FA." text_2fa_disabled: "O utilizador não configurou um dispositivo 2FA através da sua página \"A minha conta\"" - only_sms_allowed: "Only SMS delivery can be set up for other users." + only_sms_allowed: "Apenas o envio de SMS pode ser configurado para outros utilizadores." upsale: title: "Autenticação de dois fatores" description: "Reforce a segurança da sua instância do OpenProject ao oferecer (ou reforçar) a autenticação de dois fatores a todos os membros do projeto." @@ -114,12 +114,12 @@ pt-PT: failed_to_delete: "Falha ao excluir o dispositivo 2FA." is_default_cannot_delete: "O dispositivo está marcado como padrão e não pode ser excluído devido a uma política de segurança ativa. Marque outro dispositivo como padrão antes de excluir." not_existing: "Nenhum dispositivo 2FA foi registrado para sua conta." - 2fa_from_input: Please enter the code from your %{device_name} to verify your identity. - 2fa_from_webauthn: Please provide the WebAuthn device %{device_name}. If it is USB based make sure to plug it in and touch it. Then click the sign in button. + 2fa_from_input: Introduza o código do seu %{device_name} para verificar a sua identidade. + 2fa_from_webauthn: Indique o dispositivo WebAuthn %{device_name}. Se for baseado em USB, certifique-se de que o liga e toca nele. Em seguida, clique no botão de início de sessão. webauthn: title: "WebAuthn" - description: Use Web Authentication to register a FIDO2 device (like a YubiKey) or the secure enclave of your mobile device as a second factor. - further_steps: After you have chosen a name, you can click the Continue button. Your browser will prompt you to present your WebAuthn device. When you have done so, you are done registering the device. + description: Utilize a Autenticação Web para registar um dispositivo FIDO2 (como uma YubiKey) ou o enclave seguro do seu dispositivo móvel como um segundo fator. + further_steps: Depois de ter escolhido um nome, pode clicar no botão Continuar. O seu navegador irá pedir-lhe para apresentar o seu dispositivo WebAuthn. Quando o tiver feito, o registo do dispositivo está concluído. totp: title: "Use o seu autenticador baseado em aplicativos" provisioning_uri: "URI de provisionamento" From bdc688e733e5485d580267ff77f8a21e558dbf6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Mar 2024 05:39:59 +0000 Subject: [PATCH 197/218] build(deps): bump ox from 2.14.17 to 2.14.18 Bumps [ox](https://github.com/ohler55/ox) from 2.14.17 to 2.14.18. - [Changelog](https://github.com/ohler55/ox/blob/develop/CHANGELOG.md) - [Commits](https://github.com/ohler55/ox/compare/v2.14.17...v2.14.18) --- updated-dependencies: - dependency-name: ox dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a3a96368eeb2..747ae0aa5999 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -785,7 +785,7 @@ GEM openssl-signature_algorithm (1.3.0) openssl (> 2.0) os (1.1.4) - ox (2.14.17) + ox (2.14.18) paper_trail (15.1.0) activerecord (>= 6.1) request_store (~> 1.4) From 8ff86901ae858fa8de863476a2b62198a23e95d9 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 22 Mar 2024 09:12:10 +0100 Subject: [PATCH 198/218] Fix class names that where falsly replaced during the renaming of `highlighted` to `primary` --- app/views/admin/settings/work_packages_settings/show.html.erb | 2 +- .../projects/components/new-project/new-project.component.html | 2 +- frontend/src/app/shared/components/forms/form.sass | 2 +- frontend/src/app/shared/components/forms/highlighted-input.sass | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/admin/settings/work_packages_settings/show.html.erb b/app/views/admin/settings/work_packages_settings/show.html.erb index a0826dff7ea9..9708867f0c34 100644 --- a/app/views/admin/settings/work_packages_settings/show.html.erb +++ b/app/views/admin/settings/work_packages_settings/show.html.erb @@ -58,7 +58,7 @@ See COPYRIGHT and LICENSE files for more details. } %>
<% if EnterpriseToken.allows_to? :conditional_highlighting %> -
+
<%= setting_multiselect :work_package_list_default_highlighted_attributes, Query.available_columns(nil).select(&:highlightable).map { |column| [column.caption, column.name.to_s] diff --git a/frontend/src/app/features/projects/components/new-project/new-project.component.html b/frontend/src/app/features/projects/components/new-project/new-project.component.html index bea81db1c3f1..d3abe682b053 100644 --- a/frontend/src/app/features/projects/components/new-project/new-project.component.html +++ b/frontend/src/app/features/projects/components/new-project/new-project.component.html @@ -2,7 +2,7 @@ class="op-form" [formGroup]="templateForm" > -
+
.spot-form-field, > .spot-selector-field, > .op-option-list, - > .op-primaryed-input, + > .op-highlighted-input, > .button &:not(:last-child) margin-bottom: 1rem diff --git a/frontend/src/app/shared/components/forms/highlighted-input.sass b/frontend/src/app/shared/components/forms/highlighted-input.sass index bfc976f06ed5..399d9ec59267 100644 --- a/frontend/src/app/shared/components/forms/highlighted-input.sass +++ b/frontend/src/app/shared/components/forms/highlighted-input.sass @@ -1,4 +1,4 @@ -.op-primaryed-input +.op-highlighted-input padding: 1rem 1rem 0.5rem 0.75rem display: flex flex-direction: column From a155734769ab2fd65cdfa9528b6670afdd2a9516 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 22 Mar 2024 09:12:58 +0100 Subject: [PATCH 199/218] Let headers span the complete width of the screen and add more spacing between the sections --- frontend/src/app/shared/components/forms/form.sass | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/forms/form.sass b/frontend/src/app/shared/components/forms/form.sass index 95c38ed7f9a5..320267dedb3a 100644 --- a/frontend/src/app/shared/components/forms/form.sass +++ b/frontend/src/app/shared/components/forms/form.sass @@ -25,8 +25,13 @@ &:not(:last-child) margin-bottom: 1rem + &--fieldset, &--section-header - margin-top: 0.5rem + width: 100% + margin-top: 1.5rem + + &:first-child + margin-top: 0 &--section-header-title @extend %form--fieldset-legend-or-section-title From beed93d154854997f998badc2aca74d21e4ed33c Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:06:37 +0200 Subject: [PATCH 200/218] Simplify the Project settings project custom fields page. --- .../index_component.html.erb | 7 ++++--- .../index_component.rb | 6 ------ .../project_custom_fields/component_streams.rb | 2 -- .../settings/project_custom_fields_controller.rb | 14 +++++++------- .../settings/project_custom_fields/show.html.erb | 1 - 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb index 9e23142ac9e7..93fd9e55e32f 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -19,12 +19,13 @@ } )) end - @project_custom_fields_grouped_by_section.each do |section_id, project_custom_fields| + + @project_custom_field_sections.each do |project_custom_field_section| flex.with_row do render(Projects::Settings::ProjectCustomFieldSections::ShowComponent.new( project: @project, - project_custom_field_section: get_eager_loaded_project_custom_field_section(section_id), - project_custom_fields:, + project_custom_field_section:, + project_custom_fields: project_custom_field_section.custom_fields, project_custom_field_project_mappings: @project_custom_field_project_mappings )) end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb index d635000fc713..3eeb76674427 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -37,14 +37,12 @@ class IndexComponent < ApplicationComponent def initialize( project:, project_custom_field_sections:, - project_custom_fields_grouped_by_section:, project_custom_field_project_mappings: ) super @project = project @project_custom_field_sections = project_custom_field_sections - @project_custom_fields_grouped_by_section = project_custom_fields_grouped_by_section @project_custom_field_project_mappings = project_custom_field_project_mappings end @@ -56,10 +54,6 @@ def wrapper_data_attributes 'application-target': 'dynamic' } end - - def get_eager_loaded_project_custom_field_section(section_id) - @project_custom_field_sections.find { |section| section.id == section_id } - end end end end diff --git a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb index 1e1f1cd5f2d2..1ed5c490542e 100644 --- a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb @@ -36,14 +36,12 @@ module ComponentStreams def update_sections_via_turbo_stream( project: @project, project_custom_field_sections: @project_custom_field_sections, - project_custom_fields_grouped_by_section: @project_custom_fields_grouped_by_section, project_custom_field_project_mappings: @project_custom_field_project_mappings ) update_via_turbo_stream( component: ::Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( project:, project_custom_field_sections:, - project_custom_fields_grouped_by_section:, project_custom_field_project_mappings: ) ) diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 07dc498dfaaa..950e0d4566ce 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -82,13 +82,13 @@ def disable_all_of_section private def eager_load_project_custom_field_data - @project_custom_field_sections = ProjectCustomFieldSection.all.to_a - - @project_custom_fields_grouped_by_section = ProjectCustomField - .visible - .includes(:project_custom_field_section) - .sort_by { |pcf| pcf.project_custom_field_section.position } - .group_by(&:custom_field_section_id) + # Load only the sections that have custom_fields associated + @project_custom_field_sections = + ProjectCustomFieldSection + .joins(:custom_fields) + .includes(:custom_fields) + .group(:id, "custom_fields.id") + .order(:position) @project_custom_field_project_mappings = ProjectCustomFieldProjectMapping .where(project_id: @project.id) diff --git a/app/views/projects/settings/project_custom_fields/show.html.erb b/app/views/projects/settings/project_custom_fields/show.html.erb index 0941aa2111f5..4ec951b20b7f 100644 --- a/app/views/projects/settings/project_custom_fields/show.html.erb +++ b/app/views/projects/settings/project_custom_fields/show.html.erb @@ -37,7 +37,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( project: @project, project_custom_field_sections: @project_custom_field_sections, - project_custom_fields_grouped_by_section: @project_custom_fields_grouped_by_section, project_custom_field_project_mappings: @project_custom_field_project_mappings, )) %>
From 4d96b7786b6b72a5351368fa13a26b7e2e89a88c Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 22 Mar 2024 17:36:02 +0200 Subject: [PATCH 201/218] Add visible scope --- .../projects/settings/project_custom_fields_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 950e0d4566ce..caec7e41d2b0 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -82,11 +82,12 @@ def disable_all_of_section private def eager_load_project_custom_field_data - # Load only the sections that have custom_fields associated + # Load only the sections that have visible custom_fields associated @project_custom_field_sections = ProjectCustomFieldSection .joins(:custom_fields) .includes(:custom_fields) + .merge(ProjectCustomField.visible) .group(:id, "custom_fields.id") .order(:position) From b5866a620af076a28439397fe606a79eb5bb66b6 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 22 Mar 2024 19:19:37 +0200 Subject: [PATCH 202/218] Simplify the project acts as customizable patch, load all the field data in one query and sort it via sql instead of ruby. --- .../show_component.html.erb | 2 +- .../show_component.rb | 4 ---- .../project_custom_fields_controller.rb | 2 +- app/forms/projects/custom_fields/form.rb | 23 ++++++++++--------- .../projects/acts_as_customizable_patches.rb | 17 +------------- .../sections/show_component.html.erb | 2 +- .../sections/show_component.rb | 4 ---- .../sidebar_component.html.erb | 4 ++-- .../sidebar_component.rb | 11 +++------ .../overviews/overviews_controller.rb | 11 +-------- .../project_custom_fields_sidebar.html.erb | 5 +--- 11 files changed, 23 insertions(+), 62 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 78f28405247e..3571e88b9e71 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -59,7 +59,7 @@ render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } end else - sorted_project_custom_fields.each do |project_custom_field| + @project_custom_fields.each do |project_custom_field| component.with_row(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'searchItem' }) do render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( project: @project, diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb index beb8c9dee73e..a1ef3289deb5 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -53,10 +53,6 @@ def initialize( def wrapper_uniq_by @project_custom_field_section.id end - - def sorted_project_custom_fields - @project_custom_fields.sort_by { |pcf| pcf.position_in_custom_field_section } - end end end end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index caec7e41d2b0..b4c3e7527a07 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -89,7 +89,7 @@ def eager_load_project_custom_field_data .includes(:custom_fields) .merge(ProjectCustomField.visible) .group(:id, "custom_fields.id") - .order(:position) + .order(:position, :position_in_custom_field_section) @project_custom_field_project_mappings = ProjectCustomFieldProjectMapping .where(project_id: @project.id) diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb index 5d4c00542f7d..3a7ba99892b8 100644 --- a/app/forms/projects/custom_fields/form.rb +++ b/app/forms/projects/custom_fields/form.rb @@ -28,7 +28,7 @@ module Projects::CustomFields class Form < ApplicationForm form do |custom_fields_form| - sorted_custom_fields.each do |custom_field| + custom_fields.each do |custom_field| custom_fields_form.fields_for(:custom_field_values) do |builder| custom_field_input(builder, custom_field) end @@ -50,16 +50,17 @@ def initialize(project:, custom_field_section: nil, custom_field: nil, wrapper_i private - def sorted_custom_fields - return @custom_fields if @custom_fields.present? - - @custom_fields = if @custom_field.present? - [@custom_field] - elsif @custom_field_section.present? - @project.sorted_available_custom_fields_by_section(@custom_field_section) - else - @project.sorted_available_custom_fields - end + def custom_fields + @custom_fields ||= + if @custom_field.present? + [@custom_field] + elsif @custom_field_section.present? + @project + .available_custom_fields + .where(custom_field_section: @custom_field_section) + else + @project.available_custom_fields + end end def custom_field_input(builder, custom_field) diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb index cde7c21725fd..41441fe6cc7f 100644 --- a/app/models/projects/acts_as_customizable_patches.rb +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -120,6 +120,7 @@ def available_custom_fields(global: false) custom_fields = ProjectCustomField .visible .includes(:project_custom_field_section) + .order("custom_field_sections.position", :position_in_custom_field_section) # available_custom_fields is called from within the acts_as_customizable module # we don't want to adjust these calls, but need a way to query the available custom fields on a global level in some cases @@ -137,22 +138,6 @@ def available_custom_fields(global: false) custom_fields end - def available_project_custom_fields_grouped_by_section - sorted_available_custom_fields - .group_by(&:custom_field_section_id) - end - - def sorted_available_custom_fields - available_custom_fields - .sort_by { |pcf| [pcf.project_custom_field_section&.position, pcf.position_in_custom_field_section] } - end - - def sorted_available_custom_fields_by_section(section) - available_custom_fields - .where(custom_field_section_id: section.id) - .sort_by(&:position_in_custom_field_section) - end - def validate_custom_values # validate custom values only of a specified section # instead of validating ALL custom values like done in acts_as_customizable diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb index 57be4beb9113..b42340b6e217 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -25,7 +25,7 @@ end end - sorted_project_custom_fields.each do |project_custom_field| + @project_custom_fields.each do |project_custom_field| details_container.with_row(mb: 3) do render(ProjectCustomFields::Sections::ProjectCustomFields::ShowComponent.new( project_custom_field: project_custom_field, diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb index e3352cad3e92..bce4f3bf995a 100644 --- a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb @@ -60,10 +60,6 @@ def eager_load_project_custom_field_values .to_a end - def sorted_project_custom_fields - @project_custom_fields.sort_by { |pcf| pcf.position_in_custom_field_section } - end - def get_eager_loaded_project_custom_field_values_for(custom_field_id) @eager_loaded_project_custom_field_values.select { |pcfv| pcfv.custom_field_id == custom_field_id } end diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb index de5672b3b162..6f8f746c66ec 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -2,11 +2,11 @@ component_wrapper do if available_project_custom_fields_grouped_by_section.any? flex_layout(data: { qa_selector: "project-custom-fields-sidebar-async-content" }) do |sections_container| - available_project_custom_fields_grouped_by_section.each do |project_custom_field_section_id, project_custom_fields| + available_project_custom_fields_grouped_by_section.each do |project_custom_field_section, project_custom_fields| sections_container.with_row(mb: 3) do render(ProjectCustomFields::Sections::ShowComponent.new( project: @project, - project_custom_field_section: get_eager_loaded_project_custom_field_section(project_custom_field_section_id), + project_custom_field_section: , project_custom_fields: project_custom_fields )) end diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.rb b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb index 2b6731ca7625..73350be6ecd2 100644 --- a/modules/overviews/app/components/project_custom_fields/sidebar_component.rb +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb @@ -32,21 +32,16 @@ class SidebarComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:, eager_loaded_project_custom_field_sections:) + def initialize(project:) super @project = project - @eager_loaded_project_custom_field_sections = eager_loaded_project_custom_field_sections end private - - def get_eager_loaded_project_custom_field_section(project_custom_field_section_id) - @eager_loaded_project_custom_field_sections.find { |pcfs| pcfs.id == project_custom_field_section_id } - end - def available_project_custom_fields_grouped_by_section - @available_project_custom_fields_grouped_by_section ||= @project.available_project_custom_fields_grouped_by_section + @available_project_custom_fields_grouped_by_section ||= + @project.available_custom_fields.group_by(&:project_custom_field_section) end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 9bd492f62c78..386b86988a5d 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -8,8 +8,6 @@ class OverviewsController < ::Grids::BaseInProjectController menu_item :overview def project_custom_fields_sidebar - @eager_loaded_project_custom_field_sections = eager_loaded_project_custom_field_sections - render :project_custom_fields_sidebar, layout: false end @@ -70,15 +68,8 @@ def handle_errors(project_with_errors, section) def update_sidebar_component update_via_turbo_stream( - component: ProjectCustomFields::SidebarComponent.new( - project: @project, - eager_loaded_project_custom_field_sections: - ) + component: ProjectCustomFields::SidebarComponent.new(project: @project) ) end - - def eager_loaded_project_custom_field_sections - ProjectCustomFieldSection.all.to_a - end end end diff --git a/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb b/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb index f156037180d8..33deb6558c3c 100644 --- a/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb +++ b/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb @@ -27,8 +27,5 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= content_tag("turbo-frame", id: "project-custom-fields-sidebar") do %> - <%= render(ProjectCustomFields::SidebarComponent.new( - project: @project, - eager_loaded_project_custom_field_sections: @eager_loaded_project_custom_field_sections - )) %> + <%= render(ProjectCustomFields::SidebarComponent.new(project: @project)) %> <% end %> \ No newline at end of file From d05351b1d812de3c3b4b2755ec30e6f93fe149d9 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 22 Mar 2024 20:06:34 +0200 Subject: [PATCH 203/218] Do not pass down the project.project_custom_field_project_mappings to the CustomFieldRowComponent but call it from the project object. This is safe to do, because we are calling the project_custom_field_project_mappings on the same project object multiple times and this won't result in an N+1 query. --- .../custom_field_row_component.rb | 4 ++-- .../index_component.html.erb | 4 +--- .../project_custom_field_sections/index_component.rb | 7 +------ .../show_component.html.erb | 1 - .../project_custom_field_sections/show_component.rb | 10 ++-------- .../project_custom_fields/component_streams.rb | 3 +++ .../project_custom_fields/component_streams.rb | 6 ++---- .../settings/project_custom_fields_controller.rb | 4 ---- .../settings/project_custom_fields/show.html.erb | 1 - 9 files changed, 11 insertions(+), 29 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 4186ad637aa3..3cb77666b6db 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -34,12 +34,12 @@ class CustomFieldRowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:, project_custom_field:, project_custom_field_project_mappings:) + def initialize(project:, project_custom_field:) super @project = project @project_custom_field = project_custom_field - @project_custom_field_project_mappings = project_custom_field_project_mappings + @project_custom_field_project_mappings = project.project_custom_field_project_mappings end private diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb index 93fd9e55e32f..5da79313acf2 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -24,9 +24,7 @@ flex.with_row do render(Projects::Settings::ProjectCustomFieldSections::ShowComponent.new( project: @project, - project_custom_field_section:, - project_custom_fields: project_custom_field_section.custom_fields, - project_custom_field_project_mappings: @project_custom_field_project_mappings + project_custom_field_section: )) end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb index 3eeb76674427..38f061e9a5f3 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -34,16 +34,11 @@ class IndexComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize( - project:, - project_custom_field_sections:, - project_custom_field_project_mappings: - ) + def initialize(project:, project_custom_field_sections:) super @project = project @project_custom_field_sections = project_custom_field_sections - @project_custom_field_project_mappings = project_custom_field_project_mappings end private diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 3571e88b9e71..58f145a2f550 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -64,7 +64,6 @@ render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( project: @project, project_custom_field:, - project_custom_field_project_mappings: @project_custom_field_project_mappings, )) end end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb index a1ef3289deb5..df2b2c634ae7 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -34,18 +34,12 @@ class ShowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize( - project:, - project_custom_field_section:, - project_custom_fields:, - project_custom_field_project_mappings: - ) + def initialize(project:, project_custom_field_section:) super @project = project @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_fields - @project_custom_field_project_mappings = project_custom_field_project_mappings + @project_custom_fields = project_custom_field_section.custom_fields end private diff --git a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb index a9b35de02cfa..7acd2087bb58 100644 --- a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb @@ -42,6 +42,9 @@ def update_header_via_turbo_stream def update_section_via_turbo_stream(project_custom_field_section:) update_via_turbo_stream( component: ::Settings::ProjectCustomFieldSections::ShowComponent.new( + # Note: `first_and_last:` argument is necessary here, because we render + # a single custom field section, and not a list of sections. Calling first? + # and last? method in the component will not result in an N+1 in this case. project_custom_field_section: ) ) diff --git a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb index 1ed5c490542e..0b126e565c40 100644 --- a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb @@ -35,14 +35,12 @@ module ComponentStreams included do def update_sections_via_turbo_stream( project: @project, - project_custom_field_sections: @project_custom_field_sections, - project_custom_field_project_mappings: @project_custom_field_project_mappings + project_custom_field_sections: @project_custom_field_sections ) update_via_turbo_stream( component: ::Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( project:, - project_custom_field_sections:, - project_custom_field_project_mappings: + project_custom_field_sections: ) ) end diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index b4c3e7527a07..9dbcb329fe5a 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -90,10 +90,6 @@ def eager_load_project_custom_field_data .merge(ProjectCustomField.visible) .group(:id, "custom_fields.id") .order(:position, :position_in_custom_field_section) - - @project_custom_field_project_mappings = ProjectCustomFieldProjectMapping - .where(project_id: @project.id) - .to_a end def set_project_custom_field_section diff --git a/app/views/projects/settings/project_custom_fields/show.html.erb b/app/views/projects/settings/project_custom_fields/show.html.erb index 4ec951b20b7f..c6fae909ca8f 100644 --- a/app/views/projects/settings/project_custom_fields/show.html.erb +++ b/app/views/projects/settings/project_custom_fields/show.html.erb @@ -37,7 +37,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( project: @project, project_custom_field_sections: @project_custom_field_sections, - project_custom_field_project_mappings: @project_custom_field_project_mappings, )) %>
From 3f723012d1063d76191200066ae90d9accc34fde Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 22 Mar 2024 21:40:44 +0200 Subject: [PATCH 204/218] Fix typo --- app/forms/projects/custom_fields/form.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb index 3a7ba99892b8..1508c068aae0 100644 --- a/app/forms/projects/custom_fields/form.rb +++ b/app/forms/projects/custom_fields/form.rb @@ -57,7 +57,7 @@ def custom_fields elsif @custom_field_section.present? @project .available_custom_fields - .where(custom_field_section: @custom_field_section) + .where(custom_field_section_id: @custom_field_section.id) else @project.available_custom_fields end From b23b803a926fe28745e6b9b69a1b18d5c716eeaa Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Sat, 23 Mar 2024 03:05:25 +0000 Subject: [PATCH 205/218] update locales from crowdin [ci skip] --- config/locales/crowdin/lt.yml | 6 +- config/locales/crowdin/ms.yml | 2 +- .../backlogs/config/locales/crowdin/ms.yml | 4 +- modules/budgets/config/locales/crowdin/ms.yml | 2 +- .../config/locales/crowdin/lt.yml | 2 +- .../config/locales/crowdin/js-ms.yml | 2 +- .../overviews/config/locales/crowdin/ms.yml | 4 +- .../recaptcha/config/locales/crowdin/ms.yml | 12 +-- .../config/locales/crowdin/js-ms.yml | 4 +- .../reporting/config/locales/crowdin/ms.yml | 92 +++++++++---------- .../storages/config/locales/crowdin/ms.yml | 88 +++++++++--------- 11 files changed, 109 insertions(+), 109 deletions(-) diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index a8e3c504a32b..cef202c11340 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -2770,9 +2770,9 @@ lt: setting_apiv3_cors_origins: "API V3 Cross-Origin Resource Sharing (CORS) leidžiami kilmės domenai" setting_apiv3_cors_origins_text_html: > Jei CORS yra įgalinta, tai yra kilmės domenai, kuriems leidžiama pasiekti OpenProject API.
Kaip nurodyti reikšmes aprašoma Origin antraštės dokumentacijoje. - setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" + setting_apiv3_write_readonly_attributes: "Rašymo prieiga prie tik skaitymui skirtų atributų" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Įjungus API leis administratoriams kūrimo metu rašyti statinius tik skaitymui skirtus atributus, tokiu kaip createdAt ir updatedAt laiko žymas.
Įspėjimas: Šis nustatymas naudojamas pavyzdžiui importuojant duomenis, bet leidžia administratoriams apsimesti, kad elementus sukuria kiti naudotojai. Visos kūrimo užklausos žurnalizuojamos su tikru autoriumi.
Daugiau informacijos apie atributus ir palaikomus resursus rasite %{api_documentation_link}. setting_apiv3_max_page_size: "Maksimalus API puslapio dydis" setting_apiv3_max_page_instructions_html: > Nustatykite maksimalų API atsakymo puslapio dydį. Bus neįmanoma vykdyti API užklausas, kurios grąžina daugiau reikšmių viename puslapyje.
Įspėjimas Prašome keisti šią reikšmę jei jums to tikrai reikia. Nustatyta didelė reikšmė stipriai įtakos greitaveiką, o maža reikšmė mažesnė už puslapiavimo parinktis reikš klaida puslapiuojamuose vaizduose. @@ -3153,7 +3153,7 @@ lt: status_user_and_brute_force: "%{user} ir %{brute_force}" status_change: "Būsenos keitimas" text_change_disabled_for_provider_login: "Vardą nustatė jūsų prisijungimo tarnyba, todėl jis negali būti pakeistas." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "Vardą ir e-paštą nustato LDAP ir todėl to negalima keisti." unlock: "Atrakinti" unlock_and_reset_failed_logins: "Atrakintiir iš naujo nustatyti nepavykusius prisijungimus" version_status_closed: "uždarytas" diff --git a/config/locales/crowdin/ms.yml b/config/locales/crowdin/ms.yml index a74b29fdac00..263e8074d387 100644 --- a/config/locales/crowdin/ms.yml +++ b/config/locales/crowdin/ms.yml @@ -2030,7 +2030,7 @@ ms: label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" - label_report: "Report" + label_report: "Laporan" label_report_bug: "Report a bug" label_report_plural: "Reports" label_reported_work_packages: "Reported work packages" diff --git a/modules/backlogs/config/locales/crowdin/ms.yml b/modules/backlogs/config/locales/crowdin/ms.yml index 2e7280ed6150..6bdf1f780351 100644 --- a/modules/backlogs/config/locales/crowdin/ms.yml +++ b/modules/backlogs/config/locales/crowdin/ms.yml @@ -99,7 +99,7 @@ ms: error_backlogs_task_cannot_be_story: "Seting tidak sah. Jenis tugasan yang dipilih tidak boleh dijadikan jenis cerita juga." error_intro_plural: "Ralat-ralat berikut telah ditemui:" error_intro_singular: "Ralat berikut telah ditemui:" - error_outro: "Sila betulkan ralat-ralat diatas sebelum menghantar semula." + error_outro: "Sila betulkan ralat-ralat di atas sebelum menghantar semula." event_sprint_description: "%{summary}: %{url}\n%{description}" event_sprint_summary: "%{project}: %{summary}" ideal: "ideal" @@ -112,7 +112,7 @@ ms: label_burndown: "Burndown" label_column_in_backlog: "Kolum dalam tunggakan" label_hours: "jam" - label_work_package_hierarchy: "Hierarki Pakej kerja " + label_work_package_hierarchy: "Hierarki Pakej kerja" label_master_backlog: "Tunggakan Utama" label_not_prioritized: "tidak diutamakan" label_points: "mata" diff --git a/modules/budgets/config/locales/crowdin/ms.yml b/modules/budgets/config/locales/crowdin/ms.yml index eb41079dc982..d4ceeae878a2 100644 --- a/modules/budgets/config/locales/crowdin/ms.yml +++ b/modules/budgets/config/locales/crowdin/ms.yml @@ -34,7 +34,7 @@ ms: status: "Status" subject: "Subjek" type: "Jenis kos" - labor_budget: "Kos buruh yang dirancang" + labor_budget: "Kos buruh terancang" material_budget: "Kos unit yang dirancang" work_package: budget_subject: "Tajuk anggaran" diff --git a/modules/gitlab_integration/config/locales/crowdin/lt.yml b/modules/gitlab_integration/config/locales/crowdin/lt.yml index f5550ea73e12..0c7ba744fb4e 100644 --- a/modules/gitlab_integration/config/locales/crowdin/lt.yml +++ b/modules/gitlab_integration/config/locales/crowdin/lt.yml @@ -33,7 +33,7 @@ lt: labels: invalid_schema: "turi būti masyvas raktų: spalva, antraštė" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "Rodyti GitLab turinį" gitlab_integration: merge_request_opened_comment: > **MR Atidarytas:** Suliejimo prašymą %{mr_number} [%{mr_title}](%{mr_url}) skirtą [%{repository}](%{repository_url}) atidarė [%{gitlab_user}](%{gitlab_user_url}). diff --git a/modules/overviews/config/locales/crowdin/js-ms.yml b/modules/overviews/config/locales/crowdin/js-ms.yml index ce56c542e74a..351a2df46c34 100644 --- a/modules/overviews/config/locales/crowdin/js-ms.yml +++ b/modules/overviews/config/locales/crowdin/js-ms.yml @@ -1,4 +1,4 @@ ms: js: overviews: - label: 'Overview' + label: 'Ringkasan' diff --git a/modules/overviews/config/locales/crowdin/ms.yml b/modules/overviews/config/locales/crowdin/ms.yml index 7ce6b82e59b0..0017c72bc4cb 100644 --- a/modules/overviews/config/locales/crowdin/ms.yml +++ b/modules/overviews/config/locales/crowdin/ms.yml @@ -1,4 +1,4 @@ ms: overviews: - label: 'Overview' - permission_manage_overview: 'Manage overview page' + label: 'Ringkasan' + permission_manage_overview: 'Urus halaman ringkasan' diff --git a/modules/recaptcha/config/locales/crowdin/ms.yml b/modules/recaptcha/config/locales/crowdin/ms.yml index 6b72f03df7bb..f633df7b0ed1 100644 --- a/modules/recaptcha/config/locales/crowdin/ms.yml +++ b/modules/recaptcha/config/locales/crowdin/ms.yml @@ -5,18 +5,18 @@ ms: description: "This module provides recaptcha checks during login." recaptcha: label_recaptcha: "reCAPTCHA" - button_please_wait: 'Please wait ...' - verify_account: "Verify your account" - error_captcha: "Your account could not be verified. Please contact an administrator." + button_please_wait: 'Sila tunggu ...' + verify_account: "Sahkan akaun anda" + error_captcha: "Akaun anda tidak dapat disahkan. Sila hubungi pengurus." settings: website_key: 'Website key' - response_limit: 'Response limit for HCaptcha' + response_limit: 'Had tindak balas untuk Hcaptcha' response_limit_text: 'The maximum number of characters to treat the HCaptcha response as valid.' website_key_text: 'Enter the website key you created on the reCAPTCHA admin console for this domain.' secret_key: 'Secret key' secret_key_text: 'Enter the secret key you created on the reCAPTCHA admin console.' - type: 'Use reCAPTCHA' - type_disabled: 'Disable reCAPTCHA' + type: 'Gunakan reCAPTCHA' + type_disabled: 'Nyahaktifkan reCAPTCHA' type_v2: 'reCAPTCHA v2' type_v3: 'reCAPTCHA v3' type_hcaptcha: 'HCaptcha' diff --git a/modules/reporting/config/locales/crowdin/js-ms.yml b/modules/reporting/config/locales/crowdin/js-ms.yml index d11c0e7c5a91..cb941b73531b 100644 --- a/modules/reporting/config/locales/crowdin/js-ms.yml +++ b/modules/reporting/config/locales/crowdin/js-ms.yml @@ -22,5 +22,5 @@ ms: js: reporting_engine: - label_remove: "Delete" - label_response_error: "There was an error handling the query." + label_remove: "Hapus" + label_response_error: "Terdapat ralat semasa mengendalikan pertanyaan." diff --git a/modules/reporting/config/locales/crowdin/ms.yml b/modules/reporting/config/locales/crowdin/ms.yml index f4d731246204..72942a3c31f6 100644 --- a/modules/reporting/config/locales/crowdin/ms.yml +++ b/modules/reporting/config/locales/crowdin/ms.yml @@ -23,48 +23,48 @@ ms: plugin_openproject_reporting: name: "OpenProject Reporting" description: "This plugin allows creating custom cost reports with filtering and grouping created by the OpenProject Time and costs plugin." - button_save_as: "Save report as..." - comments: "Comment" - cost_reports_title: "Time and costs" - label_cost_report: "Cost report" - label_cost_report_plural: "Cost reports" - description_drill_down: "Show details" - description_filter_selection: "Selection" + button_save_as: "Simpan laporan sebagai..." + comments: "Komen" + cost_reports_title: "Masa dan kos" + label_cost_report: "Laporan kos" + label_cost_report_plural: "Laporan kos" + description_drill_down: "Papar butiran" + description_filter_selection: "Pilihan" description_multi_select: "Show multiselect" description_remove_filter: "Remove filter" information_restricted_depending_on_permission: "Depending on your permissions this page might contain restricted information." - label_click_to_edit: "Click to edit." - label_closed: "closed" - label_columns: "Columns" + label_click_to_edit: "Klik untuk edit." + label_closed: "ditutup" + label_columns: "Lajur" label_cost_entry_attributes: "Cost entry attributes" - label_days_ago: "during the last days" - label_entry: "Cost entry" + label_days_ago: "pada hari-hari terakhir" + label_entry: "Kemasukan kos" label_filter_text: "Filter text" label_filter_value: "Value" label_filters: "Filter" label_greater: ">" - label_is_not_project_with_subprojects: "is not (includes subprojects)" - label_is_project_with_subprojects: "is (includes subprojects)" - label_work_package_attributes: "Work package attributes" + label_is_not_project_with_subprojects: "adalah tidak (termasuk subprojek)" + label_is_project_with_subprojects: "adalah (termasuk subprojek)" + label_work_package_attributes: "Atribut pakej kerja" label_less: "<" - label_logged_by_reporting: "Logged by" - label_money: "Cash value" - label_month_reporting: "Month (Spent)" - label_new_report: "New cost report" - label_open: "open" - label_operator: "Operator" - label_private_report_plural: "Private cost reports" - label_progress_bar_explanation: "Generating report..." - label_public_report_plural: "Public cost reports" - label_really_delete_question: "Are you sure you want to delete this report?" - label_rows: "Rows" - label_saving: "Saving ..." - label_spent_on_reporting: "Date (Spent)" - label_sum: "Sum" - label_units: "Units" - label_week_reporting: "Week (Spent)" - label_year_reporting: "Year (Spent)" - label_count: "Count" + label_logged_by_reporting: "Dilog oleh" + label_money: "Nilai tunai" + label_month_reporting: "Bulan (Dihabiskan)" + label_new_report: "Laporan kos baru" + label_open: "buka" + label_operator: "Pengendali" + label_private_report_plural: "Laporan kos peribadi" + label_progress_bar_explanation: "Menjana laporan..." + label_public_report_plural: "Laporan kos umum" + label_really_delete_question: "Adakah anda pasti anda ingin menghapuskan laporan ini?" + label_rows: "Baris" + label_saving: "Menyimpan ..." + label_spent_on_reporting: "Tarikh (Dihabiskan)" + label_sum: "Jumlah" + label_units: "Unit" + label_week_reporting: "Minggu (Dihabiskan)" + label_year_reporting: "Tahun (Dihabiskan)" + label_count: "Hitung" label_filter: "Filter" label_filter_add: "Add Filter" label_filter_plural: "Filters" @@ -72,23 +72,23 @@ ms: label_group_by_add: "Add Group-by Attribute" label_inactive: "«inactive»" label_no: "Tidak" - label_none: "(no data)" - label_no_reports: "There are no cost reports yet." - label_report: "Report" + label_none: "(tiada data)" + label_no_reports: "Masih tiada laporan kos." + label_report: "Laporan" label_yes: "Ya" - load_query_question: "Report will have %{size} table cells and may take some time to render. Do you still want to try rendering it?" - permission_save_cost_reports: "Save public cost reports" - permission_save_private_cost_reports: "Save private cost reports" - project_module_reporting_module: "Cost reports" - text_costs_are_rounded_note: "Displayed values are rounded. All calculations are based on the non-rounded values." + load_query_question: "Laporan akan ada %{size} kotak jadual dan mungkin memerlukan sedikit masa untuk dihasilkan. Adakah anda masih mahu cuba untuk menghasilkan?" + permission_save_cost_reports: "Simpan laporan kos umum" + permission_save_private_cost_reports: "Simpan laporan kos peribadi" + project_module_reporting_module: "Laporan kos" + text_costs_are_rounded_note: "Nilai yang dipaparkan adalah dibundarkan. Semua kiraan adalah berdasarkan nilai yang tidak dibundarkan." toggle_multiselect: "activate/deactivate multiselect" - units: "Units" - validation_failure_date: "is not a valid date" - validation_failure_integer: "is not a valid integer" + units: "Unit" + validation_failure_date: "bukan tarikh yang sah" + validation_failure_integer: "bukan integer yang sah" export: cost_reports: title: "Your Cost Reports XLS export" reporting: group_by: - selected_columns: "Selected columns" - selected_rows: "Selected rows" + selected_columns: "Lajur terpilih" + selected_rows: "Baris terpilih" diff --git a/modules/storages/config/locales/crowdin/ms.yml b/modules/storages/config/locales/crowdin/ms.yml index d59102a58689..9e49faa91a77 100644 --- a/modules/storages/config/locales/crowdin/ms.yml +++ b/modules/storages/config/locales/crowdin/ms.yml @@ -12,51 +12,51 @@ ms: tenant: Directory (tenant) ID errors: messages: - not_linked_to_project: is not linked to project. + not_linked_to_project: tidak terpaut dengan projek. models: storages/file_link: attributes: origin_id: - only_numeric_or_uuid: can only be numeric or uuid. + only_numeric_or_uuid: boleh menjadi dalam format numerik atau uuid. storages/project_storage: attributes: project_folder_mode: - mode_unavailable: is not available for this storage. + mode_unavailable: tidak tersedia untuk penyimpanan ini. storages/storage: attributes: host: authorization_header_missing: is not fully set up. The Nextcloud instance does not receive the "Authorization" header, which is necessary for a Bearer token based authorization of API requests. Please double check your HTTP server configuration. - cannot_be_connected_to: can not be connected to. - minimal_nextcloud_version_unmet: does not meet minimal version requirements (must be Nextcloud 23 or higher) - not_nextcloud_server: is not a Nextcloud server - op_application_not_installed: appears to not have the app "OpenProject integration" installed. Please install it first and then try again. + cannot_be_connected_to: tidak boleh disambungkan. + minimal_nextcloud_version_unmet: tidak memenuhi keperluan versi minimum (mesti Nextcloud 23 atau lebih tinggi) + not_nextcloud_server: adalah bukan server Nextcloud + op_application_not_installed: kelihatan tidak mempunyai aplikasi "Integrasi OpenProject" dipasang. Sila pasang terlebih dahulu dan cuba sekali lagi. password: - invalid_password: is not valid. - unknown_error: could not be validated. Please check your storage connection and try again. + invalid_password: tidak sah. + unknown_error: tidak dapat disahkan. Sila periksa sambungan storan anda dan cuba lagi. models: file_link: Fail - storages/storage: Storage + storages/storage: Storan api_v3: errors: too_many_elements_created_at_once: Too many elements created at once. Expected %{max} at most, got %{actual}. permission_create_files: Cipta fail permission_delete_files: Padam fail - permission_manage_file_links: Manage file links - permission_manage_storages_in_project: Manage file storages in project + permission_manage_file_links: Urus pautan fail + permission_manage_storages_in_project: Urus fail storan dalam projek permission_read_files: Baca fail permission_share_files: Kongsi fail - permission_view_file_links: View file links + permission_view_file_links: Papar pautan fail permission_write_files: Write files - project_module_storages: File storages + project_module_storages: Storan fail storages: buttons: - complete_without_setup: Complete without it - done_complete_setup: Done, complete setup - done_continue: Done, continue - replace_oauth_application: Replace OpenProject OAuth - replace_oauth_client: Replace %{provider_type} OAuth + complete_without_setup: Lengkap tanpanya + done_complete_setup: Selesai, penyediaan lengkap + done_continue: Selesai, teruskan + replace_oauth_application: Ganti OpenProject OAuth + replace_oauth_client: Ganti %{provider_type} OAuth save_and_continue: Simpan dan teruskan - select_folder: Select folder + select_folder: Pilih folder configuration_checks: oauth_client_incomplete: nextcloud: Allow OpenProject to access Nextcloud data using OAuth. @@ -83,24 +83,24 @@ ms: oauth_applications: OAuth applications one_drive_oauth: Azure OAuth openproject_oauth: OpenProject OAuth - project_folders: Project folders + project_folders: Folder projek redirect_uri: Redirect URI storage_provider: Storage provider health: - checked: Last checked %{datetime} - label_error: Error - label_healthy: Healthy - label_pending: Pending - since: since %{datetime} + checked: Terakhir diperiksa %{datetime} + label_error: Ralat + label_healthy: Sihat + label_pending: Dalam proses + since: sejak %{datetime} subtitle: Automatic managed project folders - title: Health status + title: Status kesihatan help_texts: - project_folder: The project folder is the default folder for file uploads for this project. Users can nevertheless still upload files to other locations. + project_folder: Folder projek adalah folder default untuk muat naik fail bagi projek ini. Pengguna masih boleh muat naik fail ke lokasi lain. instructions: all_available_storages_already_added: All available storages are already added to the project. automatic_folder: This will automatically create a root folder for this project and manage the access permissions for each project member. - copy_from: Copy this value from - empty_project_folder_validation: Selecting a folder is mandatory to proceed. + copy_from: Salin nilai ini dari + empty_project_folder_validation: Memilih folder adalah wajib untuk meneruskan. existing_manual_folder: You can designate an existing folder as the root folder for this project. The permissions are however not automatically managed, the administrator needs to manually ensure relevant users have access. The selected folder can be used by multiple projects. host: Please add the host address of your storage including the https://. It should not be longer than 255 characters. managed_project_folders_application_password_caption: 'Enable automatic managed folders by copying this value from: %{provider_type_link}.' @@ -128,15 +128,15 @@ ms: oauth_configuration: Copy these values from the desired application in the %{application_link_text}. provider_configuration: Please make sure you have administration privileges in the %{application_link_text} or contact your Microsoft administrator before doing the setup. In the portal, you also need to register an Azure application or use an existing one for authentication. tenant_id: Please copy the Directory (tenant) ID from the desired application and App registrations in the %{application_link_text}. - tenant_id_placeholder: Name or UUID + tenant_id_placeholder: Nama atau UUID setting_up_additional_storages: For setting up additional file storages, please visit setting_up_additional_storages_non_admin: Administrators can set up additional file storages in Administration / File Storages. setting_up_storages: For setting up file storages, please visit setting_up_storages_non_admin: Administrators can set up file storages in Administration / File Storages. type: 'Please make sure you have administration privileges in your Nextcloud instance and have the following application installed before doing the setup:' type_link_text: "“Integration OpenProject”" - label_active: Active - label_add_new_storage: Add new storage + label_active: Aktif + label_add_new_storage: Tambah storan baru label_automatic_folder: New folder with automatically managed permissions label_completed: Completed label_creation_time: Creation time @@ -151,28 +151,28 @@ ms: label_inactive: Inactive label_incomplete: Incomplete label_managed_project_folders: - application_password: Application password + application_password: Kata laluan aplikasi automatically_managed_folders: Automatically managed folders - label_name: Name + label_name: Nama label_new_file_storage: New %{provider} storage label_new_storage: New storage - label_no_selected_folder: No selected folder + label_no_selected_folder: Tiada folder yang dipilih label_no_specific_folder: No specific folder - label_oauth_client_id: OAuth Client ID - label_openproject_oauth_application_id: OpenProject OAuth Client ID + label_oauth_client_id: OAuth ID Pelanggan + label_openproject_oauth_application_id: OpenProject OAuth ID Pelanggan label_openproject_oauth_application_secret: OpenProject OAuth Client Secret - label_project_folder: Project folder - label_provider: Provider - label_redirect_uri: Redirect URI + label_project_folder: Folder projek + label_provider: Pembekal + label_redirect_uri: Ubah hala URI label_show_storage_redirect_uri: Show redirect URI label_status: Status label_storage: Storage label_uri: URI member_connection_status: - connected: Connected + connected: Disambungkan connected_no_permissions: User role has no storages permissions - not_connected: Not connected. The user should login to the storage via the following %{link}. - members_no_results: No members to display. + not_connected: Tidak bersambung. Pengguna perlu log masuk ke dalam storan melalui %{link} berikut. + members_no_results: Tiada ahli untuk dipaparkan. no_results: No storages set up yet. notice_successful_storage_connection: |- Storage connected successfully! Remember to activate the module and the specific storage in the project settings From 129dc3d677fe2320b22dea1549bc3b1a745804b2 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Sun, 24 Mar 2024 03:05:16 +0000 Subject: [PATCH 206/218] update locales from crowdin [ci skip] --- config/locales/crowdin/js-zh-TW.yml | 42 +++++----- config/locales/crowdin/zh-TW.seeders.yml | 2 +- config/locales/crowdin/zh-TW.yml | 84 +++++++++---------- .../backlogs/config/locales/crowdin/zh-TW.yml | 8 +- .../bim/config/locales/crowdin/hu.seeders.yml | 2 +- .../costs/config/locales/crowdin/zh-TW.yml | 4 +- .../config/locales/crowdin/hu.yml | 36 ++++---- .../config/locales/crowdin/js-hu.yml | 32 +++---- .../config/locales/crowdin/zh-TW.yml | 2 +- .../storages/config/locales/crowdin/zh-TW.yml | 6 +- 10 files changed, 109 insertions(+), 109 deletions(-) diff --git a/config/locales/crowdin/js-zh-TW.yml b/config/locales/crowdin/js-zh-TW.yml index f6ff00ebd067..b2869e881948 100644 --- a/config/locales/crowdin/js-zh-TW.yml +++ b/config/locales/crowdin/js-zh-TW.yml @@ -75,7 +75,7 @@ zh-TW: button_copy_to_clipboard: "複製到剪貼簿" button_copy_link_to_clipboard: "複製到剪貼簿" button_copy_to_other_project: "複製到其他專案" - button_custom-fields: "自訂欄位" + button_custom-fields: "客製欄位" button_delete: "删除" button_delete_watcher: "刪除監看者" button_details_view: "詳細檢視" @@ -190,7 +190,7 @@ zh-TW: text: "[Placeholder] 嵌入式日曆" admin: type_form: - custom_field: "自訂欄位" + custom_field: "客製欄位" inactive: "未啟用" drag_to_activate: "從這裡拖曳欄位來啟用它們" add_group: "增加群組屬性" @@ -351,7 +351,7 @@ zh-TW: standard: learn_about_link: https://www.openproject.org/blog/openproject-13-4-release/ new_features_html: > - The release contains various new features and improvements:
  • GitLab integration (originally developed by Community contributors)
  • Advanced features for custom project lists
  • Advanced features for the Meetings module
  • Virus scanning functionality with ClamAV (Enterprise add-on)
  • PDF Export: Lists in table cells are supported
  • WebAuthn/FIDO/U2F is added as a second factor
  • More languages added to the default available set
+ 此版本包含各種新功能和改進:
  • GitLab 整合(最初由社群貢獻者開發)
  • 自訂專案清單的高級功能
  • li>
  • 會議模組的進階功能
  • 使用ClamAV(企業附加元件)進行病毒掃描功能
  • PDF 匯出:支援表格儲存格中的列表
  • < li>WebAuthn/FIDO/U2F 被加入為第二個因素
  • 新加入更多語言
ical_sharing_modal: title: "訂閱日曆" inital_setup_error_message: "更新資料時發生錯誤" @@ -765,7 +765,7 @@ zh-TW: error_no_table_configured: "請為 %{group} 配置表。" reset_title: "重設表單配置" confirm_reset: > - 警告: 確實要重置表單配置嗎?這將重置屬性到其預設組, 並禁用所有自訂欄位。 + 警告: 確實要重置表單配置嗎?這將重置屬性到其預設組, 並禁用所有客製欄位。 upgrade_to_ee: "升級至地端企業版" upgrade_to_ee_text: "哇!如果您需要使用此功能,說明您非常專業!您是否願意成為企業版客戶來支持 OpenSource 開發人員?" more_information: "更多資訊" @@ -886,7 +886,7 @@ zh-TW: image: "圖片" work_packages: bulk_actions: - move: "Bulk change of project" + move: "專案整批變動" edit: "整批編輯" copy: "整批複製" delete: "整批刪除" @@ -988,16 +988,16 @@ zh-TW: updatedAt: "更新於" versionName: "版本" version: "版本" - work: "工作" - workAlternative: "Estimated time" - remainingTime: "剩餘工作" + work: "工時" + workAlternative: "預估時間" + remainingTime: "剩餘工時" default_queries: latest_activity: "最新活動" - created_by_me: "由我創建" + created_by_me: "由我建立" assigned_to_me: "分配給我" recently_created: "最近創建的" all_open: "所有「進行中」工作" - summary: "總覽" + summary: "大綱" shared_with_users: "分配給成員" shared_with_me: "分享給我的" jump_marks: @@ -1019,7 +1019,7 @@ zh-TW: move_column_left: "欄向左移" move_column_right: "欄向右移" hide_column: "隱藏欄" - insert_columns: "Insert columns" + insert_columns: "調整欄位" filters: "篩選條件" display_sums: "顯示加總" confirm_edit_cancel: "確實要取消編輯此版面的名稱嗎?標題將還原。" @@ -1037,14 +1037,14 @@ zh-TW: is_parent: "The dates of this work package are automatically deduced from its children. Activate 'Manual scheduling' to set the dates." is_switched_from_manual_to_automatic: "The dates of this work package may need to be recalculated after switching from manual to automatic scheduling due to relationships with other work packages." sharing: - share: "分享" + share: "共享" title: "分享工作項目" show_all_users: "顯示所有使用者" selected_count: "已選取 %{count} 個" selection: mixed: "Mixed" upsale: - description: "Share work packages with users who are not members of the project." + description: "與不是專案成員的使用者共用工作項目。" table: configure_button: "設定工作項目" summary: "由工作項目和工作項目屬性列組成的表格。" @@ -1065,9 +1065,9 @@ zh-TW: show_timeline_hint: "在表格的右側顯示互動式甘特圖。可以通過在表和甘特圖之間拖動分隔線來更改其寬度。" highlighting: "突顯" highlighting_mode: - description: "以顏色凸顯" + description: "以顏色突顯" none: "無突顯" - inline: "凸顯屬性" + inline: "突顯屬性" inline_all: "所有屬性" entire_row_by: "按類型劃分的整行" status: "狀態" @@ -1086,7 +1086,7 @@ zh-TW: relation_filters: filter_work_packages_by_relation_type: "Filter work packages by relation type" tabs: - overview: 概要 + overview: 總覽 activity: 活動 relations: 關聯 watchers: 監看者 @@ -1278,14 +1278,14 @@ zh-TW: does_not_match_search: "沒有符合搜尋條件" no_results: "沒有符合搜尋條件" baseline: - toggle_title: "基準日期" + toggle_title: "差異線" clear: "清除" apply: "套用" - header_description: "基準日期開始,突顯修改過的資料" - enterprise_header_description: "Highlight changes made to this list since any point in the past with Enterprise edition." + header_description: "差異線開始,突顯修改過的資料" + enterprise_header_description: "差異線開始,突顯修改過的資料(企業版功能)" show_changes_since: "顯示差異" - baseline_comparison: "基準日比較" - help_description: "Reference time zone for the baseline." + baseline_comparison: "比較差異" + help_description: "差異線參考時區。" time_description: "當地時間: %{datetime}" time: "時間" from: "寄件者" diff --git a/config/locales/crowdin/zh-TW.seeders.yml b/config/locales/crowdin/zh-TW.seeders.yml index f0a52d9239f9..2e2d594367b5 100644 --- a/config/locales/crowdin/zh-TW.seeders.yml +++ b/config/locales/crowdin/zh-TW.seeders.yml @@ -218,7 +218,7 @@ zh-TW: name: 里程碑 work_packages: item_0: - subject: Start of project + subject: 專案開始 item_1: subject: Organize open source conference children: diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index 5cf7bbd444e0..dab7821e8341 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -77,16 +77,16 @@ zh-TW: buttons: upgrade: "立即更新" contact: "Contact us for a demo" - enterprise_info_html: "is an Enterprise add-on." + enterprise_info_html: "是企業版功能 。" upgrade_info: "Please upgrade to a paid plan to activate and start using it in your team." journal_aggregation: explanation: text: "Individual actions of a user (e.g. updating a work package twice) are aggregated into a single action if their age difference is less than the specified timespan. They will be displayed as a single action within the application. This will also delay notifications by the same amount of time reducing the number of emails being sent and will also affect %{webhook_link} delay." link: "webhook" announcements: - show_until: 顯示直到 + show_until: 只顯示到 is_active: 當前已顯示 - is_inactive: 當前未顯示 + is_inactive: 尚未顯示 antivirus_scan: not_processed_yet_message: "Downloading is blocked, as file was not scanned for viruses yet. Please try again later." quarantined_message: "A virus was detected in file '%{filename}'. It has been quarantined and is not available for download." @@ -202,7 +202,7 @@ zh-TW: description: "Custom actions are one-click shortcuts to a set of pre-defined actions that you can make available on certain work packages based on status, role, type or project." custom_fields: text_add_new_custom_field: > - 要加入自訂欄位至一個專案,您必須先建立欄位才能將它們加入至此專案。 + 要加入客製欄位至一個專案,您必須先建立欄位才能將它們加入至此專案。 is_enabled_globally: "已全域啟用" enabled_in_project: "已在專案中啟用" contained_in_type: "已包含在類型中" @@ -210,9 +210,9 @@ zh-TW: reorder_alphabetical: "Reorder values alphabetically" reorder_confirmation: "Warning: The current order of available values will be lost. Continue?" instructions: - is_required: "Mark the custom field as required. This will make it mandatory to fill in the field when creating new or updating existing resources." - is_for_all: "將自訂欄位標記為「所有現有專案」和「新專案」中可用。" - searchable: "Include the field values when using the global search functionality." + is_required: "將此欄位定義成必填。\n" + is_for_all: "將客製欄位標記為「所有現有專案」和「新專案」中可用。" + searchable: "使用全域搜尋功能時包含此欄位。" editable: "允許使用者自行編輯此欄位" visible: "Make field visible for all users (non-admins) in the project overview and displayed in the project details widget on the Project Overview." is_filter: > @@ -281,7 +281,7 @@ zh-TW: no_results_title_text: 目前沒有工作項目類別 no_results_content_text: 建立一個新工作項目類別 custom_fields: - no_results_title_text: 目前沒有自訂欄位可用。 + no_results_title_text: 目前沒有客製欄位可用。 types: no_results_title_text: 目前沒有可用的類型 form: @@ -485,7 +485,7 @@ zh-TW: activerecord: attributes: announcements: - show_until: "顯示直到" + show_until: "只顯示到" attachment: attachment_content: "附件內容" attachment_file_name: "附件檔案名稱" @@ -526,7 +526,7 @@ zh-TW: min_length: "最小長度" multi_value: "允許多選" possible_values: "可能的值" - regexp: "正則運算式" + regexp: "正規表示式" searchable: "搜索" visible: "可見" custom_value: @@ -849,7 +849,7 @@ zh-TW: group_by_hierarchies_exclusive: "與按照 \"%{group_by}\" 分組的群組相互排斥。不能同時啟動兩者。" filters: custom_fields: - inexistent: "篩選條件沒有自訂欄位。" + inexistent: "篩選條件無客製欄位。" queries/filters/base: attributes: values: @@ -982,7 +982,7 @@ zh-TW: principal: unassignable: "無法分派到專案。" version: - undeletable_archived_projects: "The version cannot be deleted as it has work packages attached to it." + undeletable_archived_projects: "該版本無法刪除,因為包含工作項目。" undeletable_work_packages_attached: "The version cannot be deleted as it has work packages attached to it." status: readonly_default_exlusive: "can not be activated for statuses that are marked default." @@ -996,7 +996,7 @@ zh-TW: category: "類別" comment: "留言" custom_action: "自訂動作" - custom_field: "自訂欄位" + custom_field: "客製欄位" "doorkeeper/application": "Oauth 應用程式" forum: "討論區" global_role: "Global role" @@ -1068,12 +1068,12 @@ zh-TW: color: "顏色" created_at: "建立於" custom_options: "可能的值" - custom_values: "自訂欄位" + custom_values: "客製欄位" date: "日期" default_columns: "預設欄" description: "說明" derived_due_date: "Derived finish date" - derived_estimated_hours: "Total work" + derived_estimated_hours: "總工時" derived_start_date: "Derived start date" display_sums: "顯示加總" due_date: "完成日期" @@ -1096,7 +1096,7 @@ zh-TW: password: "密碼" priority: "優先等級" project: "專案" - responsible: "可信賴的" + responsible: "負責人" role: "角色" roles: "角色" start_date: "起始日期" @@ -1127,7 +1127,7 @@ zh-TW: implications: > Enabling backups will allow any user with the required permissions and this backup token to download a backup containing all data of this OpenProject installation. This includes the data of all other users. info: > - You will need to generate a backup token to be able to create a backup. Each time you want to request a backup you will have to provide this token. You can delete the backup token to disable backups for this user. + 您將需要產生備份token才能建立備份。 每次您想要請求備份時,您都必須提供此token。 您可以刪除備份token以停用該備份。 verification: > Enter %{word} to confirm you want to %{action} the backup token. verification_word_reset: 重置 @@ -1213,15 +1213,15 @@ zh-TW: consent: checkbox_label: 我已注意到並同意上述情況。 failure_message: 同意失敗, 無法繼續。 - title: 使用者同意資料使用 + title: 使用者同意個資意向書 decline_warning_message: 您已拒絕同意並已登出。 user_has_consented: 使用者已同意您在給定時間配置的語句。 not_yet_consented: 使用者尚未同意, 將在下次登錄時請求。 - contact_mail_instructions: 使用者可以提出更改或移除郵寄地址。 + contact_mail_instructions: 使用者可透過系統更改或移除郵寄地址。 contact_your_administrator: 如果您想刪除您的帳戶, 請與您的管理員聯繫。 contact_this_mail_address: 如果要刪除帳戶, 請聯繫 %{mail_address}。 - text_update_consent_time: 若更改上述資訊時,請使用者再次允許。 - update_consent_last_time: "允許時間: %{update_time}" + text_update_consent_time: 若更改上述資訊時,請使用者再次同意。 + update_consent_last_time: "同意日期: %{update_time}" copy_project: title: '複製專案 %{source_project_name}' started: '開始複製專案從「%{source_project_name}」到 「%{target_project_name}」。當「%{target_project_name}」 可以使用以後,我們會盡快用郵件通知您。' @@ -1229,7 +1229,7 @@ zh-TW: failed_internal: "內部錯誤導致複製失敗。" succeeded: "專案 %{target_project_name} 建立成功" errors: "錯誤" - project_custom_fields: "專案上的自訂欄位" + project_custom_fields: "專案上的客製欄位" x_objects_of_this_type: zero: "No objects of this type" one: "One object of this type" @@ -1381,7 +1381,7 @@ zh-TW: error_auth_source_sso_failed: "單一登入 (SSO) 使用者 '%{value}' 失敗" error_can_not_archive_project: "這個專案無法封存:%{errors}" error_can_not_delete_entry: "無法刪除項目" - error_can_not_delete_custom_field: "無法刪除自訂欄位" + error_can_not_delete_custom_field: "無法刪除客製欄位" error_can_not_delete_in_use_archived_undisclosed: "There are also work packages in archived projects. You need to ask an administrator to perform the deletion to see which projects are affected." error_can_not_delete_in_use_archived_work_packages: "There are also work packages in archived projects. You need to reactivate the following projects first, before you can change the attribute of the respective work packages: %{archived_projects_urls}" error_can_not_delete_type: @@ -1684,7 +1684,7 @@ zh-TW: label_calendars_and_dates: "行事曆與日期" label_calendar_show: "顯示行事曆" label_category: "類別" - label_consent_settings: "使用者同意" + label_consent_settings: "使用者同意個資意向書" label_wiki_menu_item: 維基選單項目 label_select_main_menu_item: 選擇新的主選單項目 label_required_disk_storage: "所使用的磁碟空間" @@ -1730,8 +1730,8 @@ zh-TW: label_current_status: "目前狀態" label_current_version: "目前版本" label_custom_field_add_no_type: "新增此欄位至一個工作項目類別" - label_custom_field_new: "新自訂欄位" - label_custom_field_plural: "自訂欄位" + label_custom_field_new: "新增欄位" + label_custom_field_plural: "客製欄位" label_custom_field_default_type: "空類型" label_custom_style: "設計" label_dashboard: "儀表板" @@ -1747,7 +1747,7 @@ zh-TW: label_delete_user: "刪除使用者" label_delete_project: "刪除專案" label_deleted: "刪除線" - label_deleted_custom_field: "(已刪除的自訂欄位)" + label_deleted_custom_field: "(已刪除的客製欄位)" label_deleted_custom_option: "(deleted option)" label_empty_element: "(空)" label_missing_or_hidden_custom_option: "(missing value or lacking permissions to access)" @@ -1772,7 +1772,7 @@ zh-TW: label_edit: "編輯" label_edit_x: "Edit: %{x}" label_enable_multi_select: "取用複選" - label_enabled_project_custom_fields: "開啟自訂欄位" + label_enabled_project_custom_fields: "開啟客製欄位" label_enabled_project_modules: "啟用的模組" label_enabled_project_activities: "啟用的時間追蹤活動" label_end_to_end: "尾端到尾端" @@ -1877,11 +1877,11 @@ zh-TW: label_logged_as: "登入為" label_login: "登入" label_custom_logo: "自訂logo" - label_custom_export_logo: "Custom export logo" + label_custom_export_logo: "客製匯出的Logo" label_custom_export_cover: "Custom export cover background" label_custom_export_cover_overlay: "Custom export cover background overlay" label_custom_export_cover_text_color: "文字顏色" - label_custom_pdf_export_settings: "Custom PDF export settings" + label_custom_pdf_export_settings: "匯出PDF設定" label_custom_favicon: "自訂圖示" label_custom_touch_icon: "自訂觸控圖示" label_logout: "登出" @@ -2270,7 +2270,7 @@ zh-TW: create_account: "To access this work package, you will need to create and activate an account on %{instance}." open_work_package: "開啟工作項目" subject: "有個工作項目 #%{id} 分享給您" - enterprise_text: "Share work packages with users who are not members of the project." + enterprise_text: "與不是專案成員的使用者共用工作項目。" summary: user: "%{user} 分享一個工作項目給您,權限:%{role_rights}\"" group: "%{user} 分享一個工作項目給您所在的群組: %{group}" @@ -2411,7 +2411,7 @@ zh-TW: notice_automatic_set_of_standard_type: "自動設定標準類型" notice_logged_out: "您已登出" notice_wont_delete_auth_source: The LDAP connection cannot be deleted as long as there are still users using it. - notice_project_cannot_update_custom_fields: "無法更新專案的可用自訂欄位。專案無效:%{errors}" + notice_project_cannot_update_custom_fields: "無法更新專案的可用客製欄位。專案無效:%{errors}" notice_attachment_migration_wiki_page: > 此頁面是因為更新 OpenProject 而自動產生的。他包含了所有之前與 %{container_type} "%{container_name}" 有關聯的附加檔案。 #Default format for numbers @@ -2730,10 +2730,10 @@ zh-TW: setting_commit_logtime_activity_id: "紀錄時間的動作" setting_commit_logtime_enabled: "啟用時間日誌" setting_commit_ref_keywords: "參照關鍵字" - setting_consent_time: "允許時間" + setting_consent_time: "同意日期" setting_consent_info: "條文" setting_consent_required: "是否需要使用者允許" - setting_consent_decline_mail: "允許列在通訊錄" + setting_consent_decline_mail: "同意郵件列在通訊錄" setting_cross_project_work_package_relations: "允許跨專案的工作項目關聯" setting_first_week_of_year: "一年的第一週從哪一天開始" setting_date_format: "日期" @@ -2903,9 +2903,9 @@ zh-TW: text_comment_wiki_page: "Wiki 頁面(%{page})的評論" text_custom_field_possible_values_info: "每個值一行" text_custom_field_hint_activate_per_project: > - 當使用自訂欄位:請留意自訂欄位需要分別被每個專案個別啟用。 + 當使用客製欄位:請留意欄位需要依專案個別啟用。 text_custom_field_hint_activate_per_project_and_type: > - 自訂欄位需要個別被工作項目類別和專案啟用。 + 客製欄位需要個別被工作項目類別和專案啟用。 text_wp_status_read_only_html: > 企業版將提供額外模組到工作項目:
  • 允許將特定狀態的工作項目設定為唯讀
text_project_custom_field_html: > @@ -2913,13 +2913,13 @@ zh-TW: text_custom_logo_instructions: > 建議使用背景透明的白色標誌。為了確保在一般及視網膜螢幕上有最佳效果,請確保您的圖片解析度在 460px X 60px。 text_custom_export_logo_instructions: > - This is the logo that appears in your PDF exports. It needs to be a PNG or JPEG image file. A black or colored logo on transparent or white background is recommended. + 這是出現在 PDF 匯出中的Logo。 它必須是 PNG 或 JPEG 影像檔。 建議在透明或白色背景上使用黑色或彩色標誌。 text_custom_export_cover_instructions: > - This is the image that appears in the background of a cover page in your PDF exports. It needs to be an about 800px width by 500px height sized PNG or JPEG image file. + 這是出現在 PDF 匯出的封面背景中的圖像。 它需要是大約 800 像素寬 x 500 像素高大小的 PNG 或 JPEG 影像檔案。 text_custom_favicon_instructions: > - This is the tiny icon that appears in your browser window/tab next to the page's title. It needs to be a squared 32 by 32 pixels sized PNG image file with a transparent background. + 這是顯示在您的瀏覽器視窗/標籤頁的標題旁邊的小圖示。它必需要是 32px X 32px 大小背景透明的 PNG 圖像檔。 text_custom_touch_icon_instructions: > - This is the icon that appears in your mobile or tablet when you place a bookmark on your homescreen. It needs to be a squared 180 by 180 pixels sized PNG image file. Please make sure the image's background is not transparent otherwise it will look bad on iOS. + 這個圖示將會顯示在您的手機或平板的主畫面中。它必須是 180px X 180px 大小的 PNG 圖檔。請確保圖片的背景不是透明的,否則在 iOS 上會看起來很醜。 text_database_allows_tsv: "資料庫允許 TSVector (非必要)" text_default_administrator_account_changed: "預設管理者帳號已更改" text_default_encoding: "預設值: UTF-8" @@ -3138,7 +3138,7 @@ zh-TW: view: "檢視" view_description: "查看此工作項目" remove: "刪除" - share: "分享" + share: "共享" text_empty_search_description: "There are no users with the current filter criteria." text_empty_search_header: "We couldn't find any matching results." text_empty_state_description: "此工作項目尚未與任何人分享" @@ -3158,7 +3158,7 @@ zh-TW: invite_resent: "已重傳邀請" not_project_member: "非專案的成員" project_group: "Group members might have additional privileges (as project members)" - not_project_group: "Group (shared with all members)" + not_project_group: "成員共享群組" additional_privileges_project: "Might have additional privileges (as project member)" additional_privileges_group: "Might have additional privileges (as group member)" additional_privileges_project_or_group: "Might have additional privileges (as project or group member)" diff --git a/modules/backlogs/config/locales/crowdin/zh-TW.yml b/modules/backlogs/config/locales/crowdin/zh-TW.yml index 537ebfc53b75..a80e037699c9 100644 --- a/modules/backlogs/config/locales/crowdin/zh-TW.yml +++ b/modules/backlogs/config/locales/crowdin/zh-TW.yml @@ -37,7 +37,7 @@ zh-TW: can_only_contain_work_packages_of_current_sprint: "只可以在現在進度中包含工作項目的 IDs" must_block_at_least_one_work_package: "必須包含至少一個項目的 ID" version_id: - task_version_must_be_the_same_as_story_version: "必須與「使用者故事」(User Story)的版本相同。" + task_version_must_be_the_same_as_story_version: "必須與上層「使用者需求」(User Story)的版本相同。" sprint: cannot_end_before_it_starts: "進度不可以在開始前結束" backlogs: @@ -64,8 +64,8 @@ zh-TW: properties: "屬性" rebuild: "重建" rebuild_positions: "重建位置" - remaining_hours: "剩餘工作" - remaining_hours_ideal: "剩餘工作(ideal)" + remaining_hours: "剩餘工時" + remaining_hours_ideal: "剩餘工時(ideal)" show_burndown_chart: "未完成圖" story: "使用者需求" story_points: "需求重點" @@ -147,7 +147,7 @@ zh-TW: rb_label_copy_tasks_none: "無" rb_label_copy_tasks_open: "開啟" rb_label_link_to_original: "包含連結到原本的使用者需求" - remaining_hours: "剩餘工作" + remaining_hours: "剩餘工時" required_burn_rate_hours: "必須的完成率 (小時)" required_burn_rate_points: "必須的完成率 (點數)" todo_work_package_description: "%{summary}: %{url}\n%{description}" diff --git a/modules/bim/config/locales/crowdin/hu.seeders.yml b/modules/bim/config/locales/crowdin/hu.seeders.yml index d68e615ba116..b1bd901e30f4 100644 --- a/modules/bim/config/locales/crowdin/hu.seeders.yml +++ b/modules/bim/config/locales/crowdin/hu.seeders.yml @@ -105,7 +105,7 @@ hu: item_1: name: Milestones item_2: - name: Tasks + name: Feladatok item_3: name: Csoport tervező boards: diff --git a/modules/costs/config/locales/crowdin/zh-TW.yml b/modules/costs/config/locales/crowdin/zh-TW.yml index b9842091a8e6..b22e03c7c0ba 100644 --- a/modules/costs/config/locales/crowdin/zh-TW.yml +++ b/modules/costs/config/locales/crowdin/zh-TW.yml @@ -32,7 +32,7 @@ zh-TW: spent_on: "日期" cost_type: unit: "單位名稱" - unit_plural: "複數單位名稱" + unit_plural: "單位名稱(複數)" work_package: costs_by_type: "支出單位" labor_costs: "工資" @@ -97,7 +97,7 @@ zh-TW: label_generic_user: "一般使用者" label_greater_or_equal: ">=" label_group_by: "分組依據" - label_group_by_add: "新增依「群組」" + label_group_by_add: "增加群組欄位" label_hourly_rate: "小時費率" label_include_deleted: "包含已刪除的" label_work_package_filter_add: "新增工作項目篩選條件" diff --git a/modules/gitlab_integration/config/locales/crowdin/hu.yml b/modules/gitlab_integration/config/locales/crowdin/hu.yml index 257b45b34344..8bcb794353e5 100644 --- a/modules/gitlab_integration/config/locales/crowdin/hu.yml +++ b/modules/gitlab_integration/config/locales/crowdin/hu.yml @@ -27,41 +27,41 @@ hu: gitlab_issue: attributes: labels: - invalid_schema: "must be an array of hashes with keys: color, title" + invalid_schema: "egy hash tömbnek kell lennie a következő kulcsokkal: color, title" gitlab_merge_request: attributes: labels: - invalid_schema: "must be an array of hashes with keys: color, title" + invalid_schema: "egy hash tömbnek kell lennie a következő kulcsokkal: color, title" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "GitLab tartalom megjelenítése" gitlab_integration: merge_request_opened_comment: > - **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **MR megnyitva:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) megnyitva [%{gitlab_user}](%{gitlab_user_url}) által. merge_request_closed_comment: > - **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **MR lezárva:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) lezárva [%{gitlab_user}](%{gitlab_user_url}) által. merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR egyesítve:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) egyesítve [%{gitlab_user}](%{gitlab_user_url}) által. merge_request_reopened_comment: > - **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **MR újranyitva:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) újranyitva [%{gitlab_user}](%{gitlab_user_url}) által. note_commit_referenced_comment: > - **Referenced in Commit:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in a Commit Note [%{commit_id}](%{commit_url}) on [%{repository}](%{repository_url}): %{commit_note} + **Hivatkozva a következő commitban:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) [%{commit_id}](%{commit_url}) commitban a(z) [%{repository}](%{repository_url}) repositoryban, az üzenet: %{commit_note} note_mr_referenced_comment: > - **Referenced in MR:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Hivatkozva a következő egyesítési kérelemben:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) %{mr_number}%{mr_title}%{mr_url} MR-ben a(z) [%{repository}](%{repository_url}) repositoryban, az üzenet: %{mr_note} note_mr_commented_comment: > - **Commented in MR:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Hozzászólva a következő egyesítési kérelemben:** [%{gitlab_user}](%{gitlab_user_url}) hozzászólt ehhez a WP-hez a(z) %{mr_number}[%{mr_title}]%{mr_url} MR-ben a(z) [%{repository}](%{repository_url}) repositoryban, az üzenet: %{mr_note} note_issue_referenced_comment: > - **Referenced in Issue:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Hivatkozva a következőben:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) %{issue_number}[%{issue_title}]%{issue_url} a(z) [%{repository}](%{repository_url}) repositoryban: %{issue_note} note_issue_commented_comment: > - **Commented in Issue:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Hozzászólva a következőben:** [%{gitlab_user}](%{gitlab_user_url}) hozzászólt ehhez a WP-hez a(z) %{issue_number}[%{issue_title}]%{issue_url} a(z) [%{repository}](%{repository_url}) repositoryban: %{issue_note} note_snippet_referenced_comment: > - **Referenced in Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) on [%{repository}](%{repository_url}): %{snippet_note} + **Hivatkozva a következőben:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) %{snippet_number}[%{snippet_title}]%{snippet_url} a(z) [%{repository}](%{repository_url}) repositoryban: %{snippet_note} issue_opened_referenced_comment: > - **Issue Opened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **Kérdés megnyitva:** %{issue_number}[%{issue_title}]%{issue_url} a következőhöz: [%{repository}](%{repository_url}) megnyitva [%{gitlab_user}](%{gitlab_user_url}) által. issue_closed_referenced_comment: > - **Issue Closed:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **Kérdés lezárva:** %{issue_number}[%{issue_title}]%{issue_url} a következőhöz: [%{repository}](%{repository_url}) lezárva [%{gitlab_user}](%{gitlab_user_url}) által. issue_reopened_referenced_comment: > - **Issue Reopened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **Kérdés újranyitva:** %{issue_number}[%{issue_title}]%{issue_url} a következőhöz: [%{repository}](%{repository_url}) újranyitva [%{gitlab_user}](%{gitlab_user_url}) által. push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Frissítés az MR-ben:** [%{gitlab_user}](%{gitlab_user_url}) frissítette a következőkkel: [%{commit_number}](%{commit_url}) a(z) [%{repository}](%{repository_url}) ekkor: %{commit_timestamp}, üzenet: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Frissítés az MR-ben:** [%{gitlab_user}](%{gitlab_user_url}) frissítette a következőkkel: [%{commit_number}](%{commit_url}) a(z) [%{repository}](%{repository_url}) ekkor: %{commit_timestamp}, üzenet: %{commit_note} diff --git a/modules/gitlab_integration/config/locales/crowdin/js-hu.yml b/modules/gitlab_integration/config/locales/crowdin/js-hu.yml index a254c559dbab..de7ad84d6a4b 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-hu.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-hu.yml @@ -26,25 +26,25 @@ hu: work_packages: tab_name: "GitLab" tab_header_issue: - title: "Issues" + title: "Kérdések" tab_header_mr: - title: "Merge requests" + title: "Egyesítési kérelmek (merge requests)" create_mr: - label: Create MR - description: Create a Merge Request + label: MR létrehozása + description: Egyesítési kérelem (merge request) létrehozása copy_menu: - label: Git snippets - description: Copy git snippets to clipboard + label: Git snippetek + description: "Git snippetek másolása a vágólapra\n" git_actions: - branch_name: Branch name - commit_message: Commit message - cmd: Create branch with empty commit - title: Quick snippets for Git - copy_success: '✅ Copied!' - copy_error: '❌ Copy failed!' + branch_name: Branch neve + commit_message: Commit üzenet + cmd: Branch létrehozása üres committal + title: Gyors snippetek a Githez + copy_success: '✅ Másolva!' + copy_error: '❌ Másolás sikertelen!' tab_issue: - empty: 'There are no issues linked yet. Link an existing issue by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the issue title/description or create a new issue.' + empty: 'Még nincsenek kapcsolódó kérdések. Hivatkozzon egy meglévő kérdésre a kérdés címében/leírásában található OP#%{wp_id} (vagy PP#%{wp_id} a privát hivatkozások esetében) kód használatával, vagy hozzon létre egy új kérdést.' tab_mrs: - empty: 'There are no merge requests linked yet. Link an existing MR by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the MR title/description or create a new MR.' - gitlab_pipelines: Pipelines - updated_on: Updated on + empty: 'Még nincsenek hozzákapcsolt egyesítési kérelmek (merge requests). Linkelj egy meglévő MR-t az OP#%{wp_id} (vagy a PP#%{wp_id} a privát linkekhez) kód használatával az MR címében/leírásában, vagy hozz létre egy új MR-t.' + gitlab_pipelines: Pipeline-ok + updated_on: Frissítve ekkor diff --git a/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml b/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml index 01490cacdcc4..40a9833d98af 100644 --- a/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml +++ b/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml @@ -33,7 +33,7 @@ zh-TW: labels: invalid_schema: "must be an array of hashes with keys: color, title" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "顯示 GitLab內容" gitlab_integration: merge_request_opened_comment: > **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). diff --git a/modules/storages/config/locales/crowdin/zh-TW.yml b/modules/storages/config/locales/crowdin/zh-TW.yml index 35a959d2f6ea..a9f90aaf36b5 100644 --- a/modules/storages/config/locales/crowdin/zh-TW.yml +++ b/modules/storages/config/locales/crowdin/zh-TW.yml @@ -197,7 +197,7 @@ zh-TW: page_titles: file_storages: delete: 刪除儲存區 - subtitle: Add an external file storage in order to upload, link and manage files in work packages. + subtitle: 新增外部檔案存儲空間,以便上傳、連結和管理工作項目中的文件。 managed_project_folders: one_drive_information: |- To enable automatically managed project folders in OneDrive/SharePoint, additional configuration is needed on @@ -231,8 +231,8 @@ zh-TW: name: OneDrive/SharePoint name_placeholder: 例如 OneDrive storage_list_blank_slate: - description: Add a storage to see them here. - heading: You don't have any storages yet. + description: 新增的儲存空間將在此顯示 + heading: 目前沒有任何儲存空間 upsale: description: |- Integrate your OneDrive/SharePoint as a file storage with OpenProject. Upload files and link them directly to From 2fb9791d85f8fb421bc119c25656c9032b4892b8 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Sun, 24 Mar 2024 21:41:52 +0200 Subject: [PATCH 207/218] Use translation to count projects on admin project custom field row --- .../custom_field_row_component.html.erb | 3 ++- .../custom_field_row_component.rb | 10 ---------- config/locales/en.yml | 4 ++++ 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index 30cac90ed3e2..bba637366eb8 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -23,7 +23,8 @@ end content_container.with_column(mr: 2) do render(Primer::Beta::Text.new(font_size: :small)) do - project_count_text + t("project.count", + count: @project_custom_field.project_custom_field_project_mappings.size) end end if @project_custom_field.required? diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index 1b203dabce3b..765ec50dc0db 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -86,16 +86,6 @@ def delete_action_item(menu) end end - def project_count_text - project_count = @project_custom_field.project_custom_field_project_mappings.size - - if project_count == 1 - "#{project_count} #{t('activerecord.models.project')}" - else - "#{project_count} #{t('label_project_plural')}" - end - end - def first? @first ||= if @first_and_last.first diff --git a/config/locales/en.yml b/config/locales/en.yml index 035ce802132b..d0a8ec18b764 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2745,6 +2745,10 @@ Project attributes and sections are defined in the Date: Mon, 25 Mar 2024 03:05:17 +0000 Subject: [PATCH 208/218] update locales from crowdin [ci skip] --- config/locales/crowdin/zh-TW.yml | 8 ++--- .../storages/config/locales/crowdin/ms.yml | 34 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index dab7821e8341..cf74ebd1e785 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -1055,8 +1055,8 @@ zh-TW: #common attributes of all models attributes: active: "啟用" - assigned_to: "負責執行者" - assignee: "負責執行者" + assigned_to: "執行者" + assignee: "執行者" attachments: "附加檔" author: "作者" base: "一般錯誤:" @@ -2564,9 +2564,9 @@ zh-TW: #which are not attributes of an AR-Model. query_fields: active_or_archived: "啟用或封存" - assigned_to_role: "負責執行者的角色" + assigned_to_role: "執行者的角色" assignee_or_group: "被指派或所屬的人或群組" - member_of_group: "負責執行者的群組" + member_of_group: "執行者的群組" name_or_identifier: "名稱或識別碼" only_subproject_id: "只限子專案" shared_with_user: "分配給成員" diff --git a/modules/storages/config/locales/crowdin/ms.yml b/modules/storages/config/locales/crowdin/ms.yml index 9e49faa91a77..28265d562136 100644 --- a/modules/storages/config/locales/crowdin/ms.yml +++ b/modules/storages/config/locales/crowdin/ms.yml @@ -6,7 +6,7 @@ ms: storages/storage: creator: Pencipta drive: Drive ID - host: Host + host: Hos name: Nama provider_type: Provider type tenant: Directory (tenant) ID @@ -38,7 +38,7 @@ ms: storages/storage: Storan api_v3: errors: - too_many_elements_created_at_once: Too many elements created at once. Expected %{max} at most, got %{actual}. + too_many_elements_created_at_once: Terlalu banyak unsur dicipta dalam satu masa. Dijangka %{max} paling banyak, mendapat %{actual}. permission_create_files: Cipta fail permission_delete_files: Padam fail permission_manage_file_links: Urus pautan fail @@ -59,26 +59,26 @@ ms: select_folder: Pilih folder configuration_checks: oauth_client_incomplete: - nextcloud: Allow OpenProject to access Nextcloud data using OAuth. - one_drive: Allow OpenProject to access Azure data using OAuth to connect OneDrive/Sharepoint. + nextcloud: Benarkan OpenProject untuk mengakses data Nextcloud menggunakan OAuth. + one_drive: Benarkan OpenProject untuk mengakses data Azure menggunakan OAuth untuk menyambung OneDrive/Sharepoint. redirect_uri_incomplete: - one_drive: Complete the setup with the correct URI redirection. + one_drive: Lengkapkan setup dengan pengalihan URI yang betul. confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? delete_warning: - input_delete_confirmation: Enter the file storage name %{file_storage} to confirm deletion. - irreversible_notice: Deleting a file storage is an irreversible action. - project_storage: 'Are you sure you want to delete %{file_storage} from this project? To confirm this action please introduce the storage name in the field below, this will:' - project_storage_delete_result_1: Remove all links from work packages of this project to files and folders of that storage. - project_storage_delete_result_2: In case this storage has an automatically managed project folder, this and its files will be deleted forever. - storage: 'Are you sure you want to delete %{file_storage}? To confirm this action please introduce the storage name in the field below, this will:' - storage_delete_result_1: Remove all storage setups for all projects using this storage. - storage_delete_result_2: Remove all links from work packages of all projects to files and folders of that storage. - storage_delete_result_3: In case this storage has automatically managed project folders, those and their contained files will be deleted forever. - error_invalid_provider_type: Please select a valid storage provider. + input_delete_confirmation: Masukkan nama fail penyimpanan %{file_storage} untuk mengesahkan pembuangan. + irreversible_notice: Mengapus fail penyimpanan adalah tindakan yang tidak dapat dipulihkan. + project_storage: 'Adakah anda pasti anda ingin menghapuskan %{file_storage} dari projek ini? Untuk mengesahkan tindakan ini sila perkenalkan nama penyimpanan di dalam medan di bawah, ini akan:' + project_storage_delete_result_1: Padam semua pautan dari pakej kerja projek ini ke fail dan folder penyimpanan tersebut. + project_storage_delete_result_2: Sekiranya penyimpanan ini mempunyai folder projek yang dikendalikan secara automatik, folder ini beserta failnya akan dipadamkan selamanya. + storage: 'Adakah anda pasti anda ingin menghapuskan %{file_storage}? Untuk mengesahkan tindakan ini sila perkenalkan nama penyimpanan di dalam medan di bawah, ini akan:' + storage_delete_result_1: Padam semua setup penyimpanan untuk semua projek yang menggunakan penyimpanan ini. + storage_delete_result_2: Padam semua pautan dari pakej kerja semua projek ke fail dan folder penyimpanan tersebut. + storage_delete_result_3: Sekiranya penyimpanan ini mempunyai folder projek yang dikendalikan secara automatik, folder tersebut beserta fail dalam kandungannya akan dipadamkan selamanya. + error_invalid_provider_type: Sila pilih pembekal penyimpanan yang sah. file_storage_view: - automatically_managed_folders: Automatically managed folders - general_information: General information + automatically_managed_folders: Folder yang dikendalikan secara automatik + general_information: Maklumat umum nextcloud_oauth: Nextcloud OAuth oauth_applications: OAuth applications one_drive_oauth: Azure OAuth From 333e7769619c20c23491108854b4ee144585d225 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Mar 2024 05:09:07 +0000 Subject: [PATCH 209/218] build(deps-dev): bump rubocop-rails from 2.24.0 to 2.24.1 Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.24.0 to 2.24.1. - [Release notes](https://github.com/rubocop/rubocop-rails/releases) - [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.24.0...v2.24.1) --- updated-dependencies: - dependency-name: rubocop-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 747ae0aa5999..9b299f0d7cef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -993,7 +993,7 @@ GEM rubocop-performance (1.20.2) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.24.0) + rubocop-rails (2.24.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) From c82de78cdd17558de3093679575ddd9fc08a7ed8 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 25 Mar 2024 09:30:21 +0200 Subject: [PATCH 210/218] Fix comment typo --- .../admin/settings/project_custom_fields/component_streams.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb index 7acd2087bb58..5073bdf20b23 100644 --- a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb +++ b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb @@ -42,7 +42,7 @@ def update_header_via_turbo_stream def update_section_via_turbo_stream(project_custom_field_section:) update_via_turbo_stream( component: ::Settings::ProjectCustomFieldSections::ShowComponent.new( - # Note: `first_and_last:` argument is necessary here, because we render + # Note: `first_and_last:` argument is not necessary here, because we render # a single custom field section, and not a list of sections. Calling first? # and last? method in the component will not result in an N+1 in this case. project_custom_field_section: From b5aed42ed4d6d1dd9a59bf58abe0cd438cfb6087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 19 Mar 2024 13:19:02 +0100 Subject: [PATCH 211/218] Add user with umlaut to ldif --- modules/ldap_groups/lib/tasks/ldap_groups.rake | 1 + spec/fixtures/ldap/users.ldif | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/modules/ldap_groups/lib/tasks/ldap_groups.rake b/modules/ldap_groups/lib/tasks/ldap_groups.rake index ff24331508a8..e23f4deb538c 100644 --- a/modules/ldap_groups/lib/tasks/ldap_groups.rake +++ b/modules/ldap_groups/lib/tasks/ldap_groups.rake @@ -128,6 +128,7 @@ namespace :ldap_groups do uid=aa729,ou=people,dc=example,dc=com (Password: smada) uid=bb459,ou=people,dc=example,dc=com (Password: niwdlab) uid=cc414,ou=people,dc=example,dc=com (Password: retneprac) + uid=bölle,ou=people,dc=example,dc=com (Password: bólle) -------------------------------------------------------- diff --git a/spec/fixtures/ldap/users.ldif b/spec/fixtures/ldap/users.ldif index e369e7cd773f..1daaeed36865 100644 --- a/spec/fixtures/ldap/users.ldif +++ b/spec/fixtures/ldap/users.ldif @@ -182,3 +182,17 @@ mail: xara@example.org uid: xx396 userpassword:: e1NIQX1ZYzJFbjJSL3NiZGpsRU9pdGtMbGt3WTRqQVk9 +dn: uid=bölle,ou=people,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: simulatedMicrosoftSecurityPrincipal +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Bölle Büllendorf +sn: Büllendorf +givenName: Bölle +mail: boelle@example.org +uid: bölle +samAccountName: bölle +# Password is "bólle" +userpassword:: e1NIQX1rNDBGWHRYQ3RFL3l2cENhblRpQmZ2cE1ON1k9Cg== From c1d5c23b1b4c0543949aab3f35711aa19fe93453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 21 Mar 2024 11:29:21 +0100 Subject: [PATCH 212/218] Allow logins to receive umlauts and other letter class chars --- app/models/user.rb | 2 +- spec/models/user_spec.rb | 14 +++++++++++++- spec/requests/auth/ldap_sso_spec.rb | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 51cce45acae2..d7cd19c38e7b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -129,7 +129,7 @@ def self.blocked_condition(blocked) validates :login, uniqueness: { if: Proc.new { |user| user.login.present? }, case_sensitive: false } validates :mail, uniqueness: { allow_blank: true, case_sensitive: false } # Login must contain letters, numbers, underscores only - validates :login, format: { with: /\A[a-z0-9_\-@.+ ]*\z/i } + validates :login, format: { with: /\A[\p{L}0-9_\-@.+ ]*\z/i } validates :login, length: { maximum: 256 } validates :firstname, :lastname, length: { maximum: 256 } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9bbcb97d49f5..121b8bdce4fc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -143,6 +143,18 @@ end end + context 'with other letter char classes' do + let(:login) { "célîneüberölig" } + + it 'is valid' do + expect(user).to be_valid + end + + it 'may be stored in the database' do + expect(user.save).to be_truthy + end + end + context "with tabs" do let(:login) { 'ab\tc' } @@ -172,7 +184,7 @@ end context "with combination thereof" do - let(:login) { "the+boss-is@the_house." } + let(:login) { "the+boss-is-über@the_house." } it "is valid" do expect(user).to be_valid diff --git a/spec/requests/auth/ldap_sso_spec.rb b/spec/requests/auth/ldap_sso_spec.rb index 5d2858a013a6..d1367a9fc0c9 100644 --- a/spec/requests/auth/ldap_sso_spec.rb +++ b/spec/requests/auth/ldap_sso_spec.rb @@ -58,6 +58,23 @@ expect(subject).to redirect_to "/?first_time_user=true" end + context 'with a user that has umlauts in their name' do + let(:username) { 'bölle' } + let(:password) { 'bólle' } + + it 'creates a user with umlauts on the fly' do + expect(User.find_by(login: 'bölle')).to be_nil + + expect { subject }.to change(User.not_builtin.active, :count).by(1) + + user = User.find_by(login: 'bölle') + expect(user).to be_present + expect(user).to be_active + expect(session[:user_id]).to eq user.id + expect(subject).to redirect_to '/?first_time_user=true' + end + end + context "when not all attributes present" do let(:attr_mail) { nil } From 3a3b8adc38e0ee4e808e247795e6c8d5865531b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 21 Mar 2024 11:33:08 +0100 Subject: [PATCH 213/218] Add some docs on ldap development --- docs/development/ldap/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/development/ldap/README.md diff --git a/docs/development/ldap/README.md b/docs/development/ldap/README.md new file mode 100644 index 000000000000..2f3a9dfb3fcd --- /dev/null +++ b/docs/development/ldap/README.md @@ -0,0 +1,31 @@ +--- +sidebar_navigation: + title: LDAP development setup + priority: 920 +--- + +# Set up a development LDAP server + +**Note:** This guide is targeted only at development with OpenProject. For the LDAP configuration guide, please see this [here](../../system-admin-guide/authentication/ldap-authentication/) + + +OpenProject comes with a built-in LDAP server for development purposes. This server uses [ladle gem](https://github.com/NUBIC/ladle) +to run an underlying apacheDS server. + +This guide will show you how to set it up in your development instance. + +## Prerequisites + +- A local java/JRE environment installed (openjdk, java installed via homebrew, etc.) +- A development setup of OpenProject (or any other configurable installation) + +## Running the LDAP server + +You only need to run this rake task to start the server: + +```bash +./bin/rails ldap_groups:development:ldap_server +``` + +It will both output the different users and groups, as well as connection details. Starting this task will ensure +an LDAP connection is created or updated to make sure you can use it right away. From 485b6e0cd44450da3a9833c1aa4d0462805bd165 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 25 Mar 2024 09:58:29 +0200 Subject: [PATCH 214/218] Use hidden by filter class on the batch actions too --- .../project_custom_field_sections/index_component.sass | 6 +++--- .../project-custom-fields-mapping-filter.controller.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.sass b/app/components/projects/settings/project_custom_field_sections/index_component.sass index c91546101fe3..9380cd6b3874 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.sass +++ b/app/components/projects/settings/project_custom_field_sections/index_component.sass @@ -1,3 +1,3 @@ -.Box-row - &.hidden-by-filter - display: none \ No newline at end of file +.Box + .hidden-by-filter + display: none diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts index 54a5a520bf1e..2cf7982df6ba 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts @@ -77,19 +77,19 @@ export default class ProjectCustomFieldsMappingFilterController extends Controll this.showBulkActionContainers(); this.searchItemTargets.forEach((item) => { - (item as HTMLElement).style.display = 'block'; + (item as HTMLElement).classList.remove('hidden-by-filter'); }); } hideBulkActionContainers() { this.bulkActionContainerTargets.forEach((item) => { - (item as HTMLElement).style.display = 'none'; + (item as HTMLElement).classList.add('hidden-by-filter'); }); } showBulkActionContainers() { this.bulkActionContainerTargets.forEach((item) => { - (item as HTMLElement).style.display = 'block'; + (item as HTMLElement).classList.remove('hidden-by-filter'); }); } } From 07766fa57f6155d44f78bded53ffeedd7eeb262d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 25 Mar 2024 08:56:53 +0100 Subject: [PATCH 215/218] Hide "add notes" when notes are present https://community.openproject.org/work_packages/53618 --- .../meeting_agenda_items/item_component/show_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb index ccb33ead7598..deac9f8fe520 100644 --- a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb @@ -70,7 +70,7 @@ ml: 2, test_selector: 'op-meeting-agenda-actions') edit_action_item(menu) if @meeting_agenda_item.editable? - add_note_action_item(menu) if @meeting_agenda_item.editable? + add_note_action_item(menu) if @meeting_agenda_item.editable? && @meeting_agenda_item.notes.blank? move_actions(menu) delete_action_item(menu) end From 94bdf1dd2646fdb94ee471565ab97a327a64dc75 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 25 Mar 2024 10:23:48 +0100 Subject: [PATCH 216/218] Add spacing to left side of the advanced filters --- frontend/src/global_styles/layout/work_packages/_mobile.sass | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/global_styles/layout/work_packages/_mobile.sass b/frontend/src/global_styles/layout/work_packages/_mobile.sass index 400df8f4005a..ce7401d65250 100644 --- a/frontend/src/global_styles/layout/work_packages/_mobile.sass +++ b/frontend/src/global_styles/layout/work_packages/_mobile.sass @@ -123,5 +123,6 @@ #content padding: 15px 0 !important - .toolbar-container + .toolbar-container, + .work-packages--filters-optional-container margin-left: 15px From 4bd203d507865a0164c97bd7ba095ad0f4c67671 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 25 Mar 2024 09:41:18 +0100 Subject: [PATCH 217/218] Re-add correct classes to the login forms. They were accidentally removed while resolving merge conflicts --- .../account/_password_login_form.html.erb | 10 ++++--- .../authentication/enter_backup_code.html.erb | 6 ++-- .../authentication/request_otp.html.erb | 30 +++++++++++-------- .../two_factor_devices/confirm.html.erb | 2 +- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/app/views/account/_password_login_form.html.erb b/app/views/account/_password_login_form.html.erb index b40d8496fb78..f58caaeefb76 100644 --- a/app/views/account/_password_login_form.html.erb +++ b/app/views/account/_password_login_form.html.erb @@ -55,10 +55,11 @@ See COPYRIGHT and LICENSE files for more details.
<% end %> - <%= submit_tag t(:button_login), - name: :login, - class: 'button -primary button_no-margin', - data: { disable_with: t(:label_loading) } %> + <% end %>
- - + <% end %> -
diff --git a/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb b/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb index d4298e181254..479c1d1d67db 100644 --- a/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb +++ b/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb @@ -43,20 +43,24 @@ <% end %> - - <% if resend_supported || has_other_devices || has_backup_codes %> - -
+ <% end %> From 78c137b98a23a687c39945beb3b20cfd8541e0f2 Mon Sep 17 00:00:00 2001 From: ulferts Date: Mon, 25 Mar 2024 10:28:09 +0100 Subject: [PATCH 218/218] increase robustness on non writable default module setting --- db/migrate/20190507132517_add_board_view_to_roles.rb | 2 +- .../20240201115019_add_gantt_module_to_default_modules.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20190507132517_add_board_view_to_roles.rb b/db/migrate/20190507132517_add_board_view_to_roles.rb index e407292e2ada..42583fd0f181 100644 --- a/db/migrate/20190507132517_add_board_view_to_roles.rb +++ b/db/migrate/20190507132517_add_board_view_to_roles.rb @@ -34,7 +34,7 @@ def up .add(:view_work_packages, :show_board_views) - unless Setting.default_projects_modules.include?("board_view") + if Setting.default_projects_modules_writable? && Setting.default_projects_modules&.exclude?("board_view") Setting.default_projects_modules = Setting.default_projects_modules + ["board_view"] end end diff --git a/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb b/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb index 80ce0a01129c..2e745e036b5f 100644 --- a/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb +++ b/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb @@ -1,6 +1,6 @@ class AddGanttModuleToDefaultModules < ActiveRecord::Migration[7.0] def up - unless Setting.default_projects_modules.include?("gantt") + if Setting.default_projects_modules_writable? && Setting.default_projects_modules&.exclude?("gantt") Setting.default_projects_modules = Setting.default_projects_modules + ["gantt"] end end