From 75d9162b88534ed02d3637b39c10735ef1b74a40 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Feb 2026 15:25:25 +0100 Subject: [PATCH 001/435] Register custom fields for inplace editing and add more field types --- .../common/inplace_edit_field_component.rb | 33 +++++++ .../boolean_input_component.rb | 74 ++++++++++++++ .../calculated_value_input_component.rb | 42 ++++++++ .../date_input_component.rb | 42 ++++++++ .../display_field_component.html.erb | 31 ++++++ .../display_fields/display_field_component.rb | 24 +++-- .../display_fields_component.sass | 16 ++-- .../rich_text_area_component.rb | 14 ++- .../float_input_component.rb | 43 +++++++++ .../integer_input_component.rb | 42 ++++++++ .../rich_text_area_component.rb | 1 - .../select_list_component.rb | 76 +++++++++++++++ .../text_input_component.rb | 1 - config/initializers/inplace_edit_fields.rb | 25 +++++ .../dynamic/inplace-edit.controller.ts | 7 ++ .../item_component.html.erb | 96 ++++++++++--------- .../project_custom_fields/item_component.rb | 5 + 17 files changed, 508 insertions(+), 64 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/date_input_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb create mode 100644 app/components/open_project/common/inplace_edit_fields/float_input_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/integer_input_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/select_list_component.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index 64fe1e2e50f..ae29a460239 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -42,6 +42,8 @@ module OpenProject @enforce_edit_mode = enforce_edit_mode @system_arguments = system_arguments @system_arguments[:id] = system_arguments[:id] || SecureRandom.uuid + @system_arguments[:required] ||= required? + @system_arguments[:label] ||= field_label end def field_class @@ -93,6 +95,37 @@ module OpenProject false end end + + def field_label + # Check if this is a custom field attribute + if attribute.to_s.start_with?("custom_field_") && custom_field + return custom_field.name + end + + label = model.class.human_attribute_name(attribute) + label = label.titleize if attribute.to_s.include?("_") + label + end + + def required? + return @required if instance_variable_defined?(:@required) + + @required = if @system_arguments.key?(:required) + @system_arguments[:required] + elsif attribute.to_s.start_with?("custom_field_") + # For custom fields, check the is_required attribute + custom_field&.is_required || false + else + # For regular model attributes, check ActiveRecord validations + model.class.validators_on(attribute).any?(ActiveRecord::Validations::PresenceValidator) + end + end + + def custom_field + return @custom_field if defined?(@custom_field) + + @custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i) + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb new file mode 100644 index 00000000000..d20307d7905 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class BooleanInputComponent < ViewComponent::Base + attr_reader :form, :attribute, :model + + def self.display_class + DisplayFields::DisplayFieldComponent + end + + def initialize(form:, attribute:, model:, **system_arguments) + super() + @form = form + @attribute = attribute + @model = model + @system_arguments = system_arguments + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + "op-inplace-edit-field--boolean" + ) + @system_arguments[:label] ||= model.class.human_attribute_name(attribute) + end + + def call + form.check_box name: attribute, + data: { controller: "inplace-edit", + action: "click->inplace-edit#submitForm" }, + **@system_arguments + end + + private + + def submit_url + inplace_edit_field_submit_path( + model: model.class.name, + id: model.id, + attribute:, + system_arguments_json: @system_arguments.to_json + ) + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb new file mode 100644 index 00000000000..12ef77d1d13 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class CalculatedValueInputComponent < InplaceEditFields::TextInputComponent + def initialize(form:, attribute:, model:, **system_arguments) + system_arguments[:readonly] = true + super + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb new file mode 100644 index 00000000000..219c102a802 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class DateInputComponent < InplaceEditFields::TextInputComponent + def initialize(form:, attribute:, model:, **system_arguments) + system_arguments[:type] = :date + super + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb new file mode 100644 index 00000000000..3da79b6846c --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb @@ -0,0 +1,31 @@ +<%= + flex_layout( + align_items: :flex_start, + justify_content: :space_between + ) do |flex| + flex.with_row(mb: 1) do + render OpenProject::Common::AttributeLabelComponent.new( + attribute:, + model:, + required: @system_arguments[:required], + hidden: @system_arguments[:visually_hide_label] + ) do + render(Primer::Beta::Text.new(font_weight: :bold)) { @system_arguments[:label] } + end + end + + flex.with_row(w: :full) do + input_specific_call + end + + flex.with_row(w: :full) do + render OpenProject::Common::AttributeHelpTextCaptionComponent.new( + help_text: helpers.help_text_for( + model, + attribute, + current_user: helpers.current_user + ) + ) + end + end +%> diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 7be8c96cfac..65906b47fcf 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -33,7 +33,7 @@ module OpenProject module InplaceEditFields module DisplayFields class DisplayFieldComponent < ViewComponent::Base - include OpenProject::TextFormatting + include OpPrimer::ComponentHelpers attr_reader :model, :attribute, :writable @@ -48,25 +48,27 @@ module OpenProject def render_display_value value = model.public_send(attribute) - if value.present? - format_text(value) + if value.is_a?(TrueClass) || value.is_a?(FalseClass) + boolean_display_value(value) + elsif value.present? + value.to_s else - "–" + t("placeholders.default") end end def display_field_arguments @display_field_arguments ||= { - classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable}", + classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable?}", data: { controller: "inplace-edit", inplace_edit_url_value: edit_url, - action: writable ? "click->inplace-edit#request" : "" + action: writable? ? "click->inplace-edit#request" : "" } } end - def call + def input_specific_call render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do render_display_value end @@ -82,6 +84,14 @@ module OpenProject system_arguments_json: @system_arguments.to_json ) end + + def boolean_display_value(value) + I18n.t("general_text_#{value ? 'yes' : 'no'}") + end + + def writable? + writable && (@system_arguments[:readonly].nil? || @system_arguments[:readonly] == false) + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass b/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass index e38a6a57830..8a2b7c9129d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass @@ -1,16 +1,12 @@ .op-inplace-edit &--display-field - &_editable - margin-left: -9px !important // cancel out 8px padding + 1px border - margin-right: -9px !important // cancel out 8px padding + 1px border - padding: var(--base-size-8) - width: calc(100% + 18px) !important - border: 1px solid transparent - border-radius: var(--borderRadius-medium) + padding: var(--base-size-4) var(--base-size-8) + border: 1px solid transparent + border-radius: var(--borderRadius-medium) - &:hover, &:focus - border-color: var(--borderColor-default) - box-shadow: var(--shadow-inset) + &:hover, &:focus + border-color: var(--borderColor-default) + box-shadow: var(--shadow-inset) &:not(&_editable) cursor: not-allowed diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb index f582d282d9c..59f7d213df6 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb @@ -33,9 +33,11 @@ module OpenProject module InplaceEditFields module DisplayFields class RichTextAreaComponent < DisplayFieldComponent + include OpenProject::TextFormatting + attr_reader :model, :attribute, :writable - def call + def input_specific_call render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do render(Primer::BaseComponent.new(tag: :div, classes: "op-uc-container op-uc-container_reduced-headings -multiline")) do @@ -43,6 +45,16 @@ module OpenProject end end end + + def render_display_value + value = model.public_send(attribute) + + if value.present? + format_text(value) + else + t("placeholders.default") + end + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/float_input_component.rb b/app/components/open_project/common/inplace_edit_fields/float_input_component.rb new file mode 100644 index 00000000000..a62ae0f5f1b --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/float_input_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class FloatInputComponent < InplaceEditFields::TextInputComponent + def initialize(form:, attribute:, model:, **system_arguments) + system_arguments[:type] = :number + system_arguments[:step] = "any" + super + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/integer_input_component.rb b/app/components/open_project/common/inplace_edit_fields/integer_input_component.rb new file mode 100644 index 00000000000..5872bc42231 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/integer_input_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class IntegerInputComponent < InplaceEditFields::TextInputComponent + def initialize(form:, attribute:, model:, **system_arguments) + system_arguments[:type] = :number + super + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index a3bb16fbf58..0c75b97bc29 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -48,7 +48,6 @@ module OpenProject @system_arguments[:classes], "op-inplace-edit-field--text-area" ) - @system_arguments[:label] ||= model.class.human_attribute_name(attribute) @system_arguments[:rich_text_options] ||= {} @system_arguments[:rich_text_options][:primerized] = true diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb new file mode 100644 index 00000000000..d94107cf7ec --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class SelectListComponent < ViewComponent::Base + attr_reader :form, :attribute, :model + + def self.display_class + DisplayFields::SelectListComponent + end + + def initialize(form:, attribute:, model:, **system_arguments) + super() + @form = form + @attribute = attribute + @model = model + @system_arguments = system_arguments + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + "op-inplace-edit-field--select-list" + ) + @system_arguments[:label] ||= model.class.human_attribute_name(attribute) + + @system_arguments[:autocomplete_options] ||= {} + @system_arguments[:autocomplete_options][:model] = model + @system_arguments[:autocomplete_options][:focusDirectly] = true + end + + def call + form.autocompleter(name: attribute, **@system_arguments) + + form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| + button_group.submit(name: :reset, + type: :submit, + label: I18n.t(:button_cancel), + scheme: :default, + formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), + formmethod: :get) + button_group.submit(name: :submit, + label: I18n.t(:button_save), + scheme: :primary) + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb index ae71deeb9b0..090187f5868 100644 --- a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb @@ -44,7 +44,6 @@ module OpenProject @attribute = attribute @model = model @system_arguments = system_arguments - @system_arguments[:label] ||= model.class.human_attribute_name(attribute) end def call diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index 5845f6864c7..018334f76ad 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -33,6 +33,31 @@ Rails.application.config.to_prepare do OpenProject::InplaceEdit::FieldRegistry.register(:description, OpenProject::Common::InplaceEditFields::RichTextAreaComponent) OpenProject::InplaceEdit::FieldRegistry.register(:status_explanation, OpenProject::Common::InplaceEditFields::RichTextAreaComponent) + # Register custom field edit components based on field format + # This mirrors the pattern used in CustomFields::CustomFieldRendering + custom_field_format_mappings = { + "string" => OpenProject::Common::InplaceEditFields::TextInputComponent, + "text" => OpenProject::Common::InplaceEditFields::RichTextAreaComponent, + "int" => OpenProject::Common::InplaceEditFields::IntegerInputComponent, + "float" => OpenProject::Common::InplaceEditFields::FloatInputComponent, + "date" => OpenProject::Common::InplaceEditFields::DateInputComponent, + "bool" => OpenProject::Common::InplaceEditFields::BooleanInputComponent, + "link" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "hierarchy" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "weighted_item_list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "user" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "version" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent + } + + CustomField.pluck(:id, :field_format).each do |id, field_format| + component_class = custom_field_format_mappings[field_format] + if component_class + OpenProject::InplaceEdit::FieldRegistry.register("custom_field_#{id}", component_class) + end + end + # Register the update handler per model OpenProject::InplaceEdit::UpdateRegistry.register(Project, handler: OpenProject::InplaceEdit::Handlers::ProjectUpdate, diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index 94d528da59d..88a312e507c 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -52,4 +52,11 @@ export default class extends Controller { throw new Error(response.statusText); } } + + submitForm() { + const form = this.element.closest('form'); + if (form) { + form.requestSubmit(); + } + } } diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb index ba92ea98b34..7a8f467f53b 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb @@ -1,49 +1,57 @@ -<%= - flex_layout( - align_items: :flex_start, - justify_content: :space_between, - classes: "op-project-custom-field-container", - data: { - test_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 OpenProject::Common::AttributeLabelComponent.new( - attribute: @project_custom_field.attribute_name, - model: @project, - required: required? - ) do - render(Primer::Beta::Text.new(font_weight: :bold)) { @project_custom_field.name } - end - end - custom_field_value_container.with_row(w: :full) do - render(authorized_edit_wrapper) do - if not_set? - render(Primer::Beta::Text.new) { t("placeholders.default") } - else - render_value +<% if show_inplace_edit_field? %> + <%= render OpenProject::Common::InplaceEditFieldComponent.new( + model: @project, + attribute: @project_custom_field.attribute_name.to_sym + ) %> +<% else %> + <%= + flex_layout( + align_items: :flex_start, + justify_content: :space_between, + classes: "op-project-custom-field-container", + data: { + test_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 OpenProject::Common::AttributeLabelComponent.new( + attribute: @project_custom_field.attribute_name, + model: @project, + required: required? + ) do + render(Primer::Beta::Text.new(font_weight: :bold)) { @project_custom_field.name } + end + end + + custom_field_value_container.with_row(w: :full) do + render(authorized_edit_wrapper) do + if not_set? + render(Primer::Beta::Text.new) { t("placeholders.default") } + else + render_value + end + end + end + + custom_field_value_container.with_row(w: :full) do + render OpenProject::Common::AttributeHelpTextCaptionComponent.new( + help_text: helpers.help_text_for( + @project, + @project_custom_field.attribute_name, + current_user: helpers.current_user + ) + ) + end + + if calculation_error? + custom_field_value_container.with_row(w: :full) do + render_calculation_error end end end + %> - custom_field_value_container.with_row(w: :full) do - render OpenProject::Common::AttributeHelpTextCaptionComponent.new( - help_text: helpers.help_text_for( - @project, - @project_custom_field.attribute_name, - current_user: helpers.current_user - ) - ) - end - - if calculation_error? - custom_field_value_container.with_row(w: :full) do - render_calculation_error - end - end - end -%> - -<%= render_calculated_value_tooltip if calculated_value? %> + <%= render_calculated_value_tooltip if calculated_value? %> +<% end %> diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb index 4518c386947..d14a5c48bc2 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb @@ -46,6 +46,11 @@ module Overviews private + def show_inplace_edit_field? + @project_custom_field.field_format != "text" || + @project_custom_field.project_custom_field_section&.shown_in_overview_main_area? + end + def allowed_to_edit? User.current.allowed_in_project?(:edit_project_attributes, @project) end From fe2cadfbab126393642d07486c0490cf24fcaa47 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 13 Feb 2026 11:05:17 +0100 Subject: [PATCH 002/435] Add support for tooltips to show tooltip for caclulated values --- .../calculated_value_input_component.rb | 5 ++ .../calculated_value_input_component.rb | 57 +++++++++++++++++++ .../display_field_component.html.erb | 2 + .../display_fields/display_field_component.rb | 5 ++ 4 files changed, 69 insertions(+) create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb diff --git a/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb index 12ef77d1d13..f72fafa0790 100644 --- a/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb @@ -32,7 +32,12 @@ module OpenProject module Common module InplaceEditFields class CalculatedValueInputComponent < InplaceEditFields::TextInputComponent + def self.display_class + DisplayFields::CalculatedValueInputComponent + end + def initialize(form:, attribute:, model:, **system_arguments) + system_arguments ||= {} system_arguments[:readonly] = true super end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb new file mode 100644 index 00000000000..3810bcec98a --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + module DisplayFields + class CalculatedValueInputComponent < DisplayFieldComponent + include OpPrimer::ComponentHelpers + + attr_reader :model, :attribute + + def initialize(model:, attribute:, **system_arguments) + @system_arguments = system_arguments + super(model:, attribute:, writable: false, **system_arguments) + end + + def render_tooltip + render Primer::Alpha::Tooltip.new( + for_id: @system_arguments[:id], + type: :description, + text: I18n.t("custom_fields.calculated_field_not_editable"), + direction: :s + ) + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb index 3da79b6846c..c9269d8d7e5 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb @@ -29,3 +29,5 @@ end end %> + +<%= render_tooltip %> diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 65906b47fcf..1393b611fb2 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -60,6 +60,7 @@ module OpenProject def display_field_arguments @display_field_arguments ||= { classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable?}", + id: @system_arguments[:id], data: { controller: "inplace-edit", inplace_edit_url_value: edit_url, @@ -74,6 +75,10 @@ module OpenProject end end + def render_tooltip + nil + end + private def edit_url From 33c5fcd61baddda15bbdcaf03bd51ef93fc03b94 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 13 Feb 2026 11:35:52 +0100 Subject: [PATCH 003/435] Add special logic for date fields to submit on change --- .../common/inplace_edit_fields/date_input_component.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb index 219c102a802..16e5e3d679d 100644 --- a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb @@ -36,6 +36,14 @@ module OpenProject system_arguments[:type] = :date super end + + def call + form.text_field name: attribute, + data: { controller: "inplace-edit", + inplace_edit_url_value: reset_url, + action: "keydown.esc->inplace-edit#request change->inplace-edit#submitForm" }, + **@system_arguments + end end end end From f7be040c27c70b46ce6f37b62aaf694bd8262e30 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 16 Feb 2026 08:59:06 +0100 Subject: [PATCH 004/435] Support link custom fields for inplace editing --- .../common/inplace_edit_field_component.rb | 8 ++- .../display_fields/link_input_component.rb | 63 +++++++++++++++++++ .../link_input_component.rb | 48 ++++++++++++++ config/initializers/inplace_edit_fields.rb | 2 +- .../dynamic/inplace-edit.controller.ts | 8 ++- .../project_custom_fields/item_component.rb | 1 + 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/link_input_component.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index ae29a460239..dec9b12a8f4 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -98,7 +98,7 @@ module OpenProject def field_label # Check if this is a custom field attribute - if attribute.to_s.start_with?("custom_field_") && custom_field + if custom_field? && custom_field return custom_field.name end @@ -112,7 +112,7 @@ module OpenProject @required = if @system_arguments.key?(:required) @system_arguments[:required] - elsif attribute.to_s.start_with?("custom_field_") + elsif custom_field? # For custom fields, check the is_required attribute custom_field&.is_required || false else @@ -121,6 +121,10 @@ module OpenProject end end + def custom_field? + attribute.to_s.start_with?("custom_field_") + end + def custom_field return @custom_field if defined?(@custom_field) diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb new file mode 100644 index 00000000000..7e5c925edda --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + module DisplayFields + class LinkInputComponent < DisplayFieldComponent + include OpenProject::TextFormatting + + attr_reader :model, :attribute, :writable + + def render_display_value + value = model.public_send(attribute) + + if value.present? + render_link(value) + else + t("placeholders.default") + end + end + + def render_link(href) + link = Addressable::URI.parse(href) + return href unless link + + target = link.host == Setting.host_without_protocol ? "_top" : "_blank" + render(Primer::Beta::Link.new(href:, rel: "noopener noreferrer", target:)) do + href + end + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/link_input_component.rb b/app/components/open_project/common/inplace_edit_fields/link_input_component.rb new file mode 100644 index 00000000000..b4301771572 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/link_input_component.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class LinkInputComponent < TextInputComponent + attr_reader :form, :attribute, :model + + def self.display_class + DisplayFields::LinkInputComponent + end + + def initialize(form:, attribute:, model:, **system_arguments) + system_arguments[:type] = :url + super + end + end + end + end +end diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index 018334f76ad..bd9261e9bdc 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -42,7 +42,7 @@ Rails.application.config.to_prepare do "float" => OpenProject::Common::InplaceEditFields::FloatInputComponent, "date" => OpenProject::Common::InplaceEditFields::DateInputComponent, "bool" => OpenProject::Common::InplaceEditFields::BooleanInputComponent, - "link" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "link" => OpenProject::Common::InplaceEditFields::LinkInputComponent, "hierarchy" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "weighted_item_list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index 88a312e507c..cba2bf0915f 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -39,7 +39,13 @@ export default class extends Controller { declare urlValue:string; - async request() { + async request(e:Event):Promise { + // Don't trigger edit mode if clicking on a link + const target = e.target as HTMLElement; + if (target.tagName === 'a' || target.closest('a')) { + return; + } + const response = await fetch(this.urlValue, { method: 'GET', headers: { Accept: 'text/vnd.turbo-stream.html' }, diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb index d14a5c48bc2..7e93f316a10 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb @@ -47,6 +47,7 @@ module Overviews private def show_inplace_edit_field? + # TODO: Move outside of this component and pass in instead @project_custom_field.field_format != "text" || @project_custom_field.project_custom_field_section&.shown_in_overview_main_area? end From dbe4e5ee52bca93515fd6d38233dedb17466a4d5 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 16 Feb 2026 11:38:44 +0100 Subject: [PATCH 005/435] Basic select list for inplace edit fields --- .../display_fields/display_field_component.rb | 10 +++ .../display_fields/select_list_component.rb | 72 +++++++++++++++++++ .../select_list_component.rb | 35 ++++++++- .../inplace_edit_fields_controller.rb | 33 ++++++++- config/initializers/inplace_edit_fields.rb | 4 +- 5 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 1393b611fb2..94073ea7a55 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -79,6 +79,16 @@ module OpenProject nil end + def custom_field? + attribute.to_s.start_with?("custom_field_") + end + + def custom_field + return @custom_field if defined?(@custom_field) + + @custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i) + end + private def edit_url diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb new file mode 100644 index 00000000000..548acb6e791 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + module DisplayFields + class SelectListComponent < DisplayFieldComponent + include CustomFieldsHelper + + attr_reader :model, :attribute, :writable + + def render_display_value + value = model.public_send(attribute) + + if value.present? && value != [nil] + if custom_field? + custom_field_values + else + value.is_a?(Array) ? value.join(", ") : value + end + else + t("placeholders.default") + end + end + + def custom_field_values + return @custom_field_values if defined?(@custom_field_values) + + values = CustomValue + .includes(custom_field: :custom_options) + .where( + custom_field_id: custom_field&.id, + customized_id: model.id + ) + .to_a + .map { |v| format_value(v.value, custom_field) } + + @custom_field_values = custom_field&.multi_value? ? values.join(", ") : values.first + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index d94107cf7ec..728009dddd5 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -51,12 +51,18 @@ module OpenProject @system_arguments[:label] ||= model.class.human_attribute_name(attribute) @system_arguments[:autocomplete_options] ||= {} - @system_arguments[:autocomplete_options][:model] = model + @system_arguments[:autocomplete_options][:model] = { id: model.id, name: model.name } + @system_arguments[:autocomplete_options][:inputName] = attribute @system_arguments[:autocomplete_options][:focusDirectly] = true + @system_arguments[:autocomplete_options][:closeOnSelect] = false end def call - form.autocompleter(name: attribute, **@system_arguments) + if custom_field? + render_custom_field_input + else + form.autocompleter(name: attribute, **@system_arguments) + end form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| button_group.submit(name: :reset, @@ -70,6 +76,31 @@ module OpenProject scheme: :primary) end end + + private + + def render_custom_field_input + input_class = if custom_field.multi_value? + CustomFields::Inputs::MultiSelectList + else + CustomFields::Inputs::SingleSelectList + end + + # Use fields_for to create the proper context for custom field inputs + form.fields_for(:custom_field_values) do |builder| + input_class.new(builder, custom_field:, object: model) + end + end + + def custom_field? + attribute.to_s.start_with?("custom_field_") + end + + def custom_field + return @custom_field if defined?(@custom_field) + + @custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i) + end end end end diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index 2b4bb22baae..b9df80a865e 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -107,8 +107,37 @@ class InplaceEditFieldsController < ApplicationController end def permitted_params - params - .expect(@model.model_name.param_key => [@attribute]) + if custom_field_via_fields_for? + transform_custom_field_values_params + else + params.expect(@model.model_name.param_key => [@attribute]) + end + end + + def custom_field_via_fields_for? + @attribute.to_s.start_with?("custom_field_") && + params[@model.model_name.param_key]&.key?(:custom_field_values) + end + + def transform_custom_field_values_params + model_key = @model.model_name.param_key + custom_field_id = @attribute.to_s.delete_prefix("custom_field_") + + # Strong Parameters doesn't support dynamic keys in nested hashes + # So we extract the value directly from the raw params + raw_value = params.dig(model_key, :custom_field_values, custom_field_id) + + # Handle both single-select and multi-select + processed_value = if raw_value.is_a?(Array) + # Remove empty strings from the hidden field + cleaned_values = raw_value.compact_blank + # For single-select, unwrap the array to get the single value + cleaned_values.size <= 1 ? cleaned_values.first : cleaned_values + else + raw_value + end + + { @attribute => processed_value } end def component(enforce_edit_mode: false) diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index bd9261e9bdc..a6e84c1d3ef 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -45,8 +45,8 @@ Rails.application.config.to_prepare do "link" => OpenProject::Common::InplaceEditFields::LinkInputComponent, "hierarchy" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "weighted_item_list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO - "list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO - "user" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "list" => OpenProject::Common::InplaceEditFields::SelectListComponent, + "user" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "version" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent } From 42ba39b0456caa0d60a139f5c4f4c601b91909c5 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 16 Feb 2026 14:51:45 +0100 Subject: [PATCH 006/435] VersionListComponent for inplaceEditComponent --- .../display_fields/select_list_component.rb | 2 +- .../select_list_component.rb | 20 +++- .../version_select_list_component.rb | 92 +++++++++++++++++++ config/initializers/inplace_edit_fields.rb | 2 +- 4 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb index 548acb6e791..bd9cd8db065 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -44,7 +44,7 @@ module OpenProject if custom_field? custom_field_values else - value.is_a?(Array) ? value.join(", ") : value + value.is_a?(Array) ? value.map(&:to_s).join(", ") : value.to_s end else t("placeholders.default") diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index 728009dddd5..38ab8b276f1 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -51,17 +51,23 @@ module OpenProject @system_arguments[:label] ||= model.class.human_attribute_name(attribute) @system_arguments[:autocomplete_options] ||= {} - @system_arguments[:autocomplete_options][:model] = { id: model.id, name: model.name } - @system_arguments[:autocomplete_options][:inputName] = attribute - @system_arguments[:autocomplete_options][:focusDirectly] = true - @system_arguments[:autocomplete_options][:closeOnSelect] = false + @system_arguments[:autocomplete_options][:model] ||= { id: model.id, name: model.name } + @system_arguments[:autocomplete_options][:inputName] ||= attribute + if @system_arguments[:autocomplete_options][:focusDirectly].nil? + @system_arguments[:autocomplete_options][:focusDirectly] = + true + end + if @system_arguments[:autocomplete_options][:closeOnSelect].nil? + @system_arguments[:autocomplete_options][:closeOnSelect] = + false + end end def call if custom_field? render_custom_field_input else - form.autocompleter(name: attribute, **@system_arguments) + render_autocompleter end form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| @@ -92,6 +98,10 @@ module OpenProject end end + def render_autocompleter + form.autocompleter(name: attribute, **@system_arguments) + end + def custom_field? attribute.to_s.start_with?("custom_field_") end diff --git a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb new file mode 100644 index 00000000000..c9ff1c199cf --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class VersionSelectListComponent < SelectListComponent + attr_reader :form, :attribute, :model + + def initialize(form:, attribute:, model:, **system_arguments) + super + + unless custom_field? + assign_defaults! + end + end + + private + + def render_custom_field_input + input_class = if custom_field.multi_value? + CustomFields::Inputs::MultiVersionSelectList + else + CustomFields::Inputs::SingleVersionSelectList + end + + # Use fields_for to create the proper context for custom field inputs + form.fields_for(:custom_field_values) do |builder| + input_class.new(builder, custom_field:, object: model) + end + end + + def render_autocompleter + form.autocompleter(name: attribute, **@system_arguments) do |list| + model.assignable_versions.each do |version| + list.option( + label: version.name, + value: version.id, + selected: version.id == model.version&.id + ) + end + end + end + + def assign_defaults! + version = model.version + @system_arguments[:autocomplete_options][:inputValue] = version&.id + @system_arguments[:autocomplete_options][:model] = version_model + @system_arguments[:autocomplete_options][:decorated] = true + @system_arguments[:autocomplete_options][:closeOnSelect] = true + # Override inputName to use Rails form builder naming convention + @system_arguments[:autocomplete_options][:inputName] = input_name + end + + def version_model + version ? { id: version.id, name: version.name } : nil + end + + def input_name + "#{model.class.model_name.param_key}[#{attribute}]" + end + end + end + end +end diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index a6e84c1d3ef..0ce06472a53 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -47,7 +47,7 @@ Rails.application.config.to_prepare do "weighted_item_list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "list" => OpenProject::Common::InplaceEditFields::SelectListComponent, "user" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO - "version" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "version" => OpenProject::Common::InplaceEditFields::VersionSelectListComponent, "calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent } From b1476e32dea418ff03ecfe30357fcad0b42c693e Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 16 Feb 2026 15:14:57 +0100 Subject: [PATCH 007/435] Basic userSelect field for inplaceEditComponent --- .../display_fields/display_field_component.rb | 10 +++ .../display_fields/select_list_component.rb | 17 ++--- .../user_select_list_component.rb | 71 +++++++++++++++++++ .../user_select_list_component.rb | 66 +++++++++++++++++ config/initializers/inplace_edit_fields.rb | 2 +- 5 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 94073ea7a55..5dc821258cb 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -107,6 +107,16 @@ module OpenProject def writable? writable && (@system_arguments[:readonly].nil? || @system_arguments[:readonly] == false) end + + def custom_field_values + CustomValue + .includes(custom_field: :custom_options) + .where( + custom_field_id: custom_field&.id, + customized_id: model.id + ) + .to_a + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb index bd9cd8db065..0cb0d112b77 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -42,7 +42,7 @@ module OpenProject if value.present? && value != [nil] if custom_field? - custom_field_values + formatted_custom_field_values else value.is_a?(Array) ? value.map(&:to_s).join(", ") : value.to_s end @@ -51,19 +51,12 @@ module OpenProject end end - def custom_field_values - return @custom_field_values if defined?(@custom_field_values) + def formatted_custom_field_values + return @formatted_custom_field_values if defined?(@formatted_custom_field_values) - values = CustomValue - .includes(custom_field: :custom_options) - .where( - custom_field_id: custom_field&.id, - customized_id: model.id - ) - .to_a - .map { |v| format_value(v.value, custom_field) } + values = custom_field_values.map { |v| format_value(v.value, custom_field) } - @custom_field_values = custom_field&.multi_value? ? values.join(", ") : values.first + @formatted_custom_field_values = custom_field&.multi_value? ? values.join(", ") : values.first end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb new file mode 100644 index 00000000000..b56a3ee43ae --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + module DisplayFields + class UserSelectListComponent < SelectListComponent + include CustomFieldsHelper + + attr_reader :model, :attribute, :writable + + def formatted_custom_field_values + return @formatted_custom_field_values if defined?(@formatted_custom_field_values) + + cf_values = custom_field_values + + users = cf_values.filter_map(&:typed_value) + + @formatted_custom_field_values = if custom_field.multi_value? + flex_layout do |avatar_container| + users.each do |user| + avatar_container.with_row do + render_avatar(user) + end + end + end + else + render_avatar(users.first) + end + end + + private + + def render_avatar(user) + return unless user + + render(::Users::AvatarComponent.new(user:, size: :mini)) + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb new file mode 100644 index 00000000000..7f82b639442 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class UserSelectListComponent < SelectListComponent + attr_reader :form, :attribute, :model + + def self.display_class + DisplayFields::UserSelectListComponent + end + + def initialize(form:, attribute:, model:, **system_arguments) + super + + unless custom_field? + assign_defaults! + end + end + + private + + def render_custom_field_input + input_class = if custom_field.multi_value? + CustomFields::Inputs::MultiUserSelectList + else + CustomFields::Inputs::SingleUserSelectList + end + + # Use fields_for to create the proper context for custom field inputs + form.fields_for(:custom_field_values) do |builder| + input_class.new(builder, custom_field:, object: model) + end + end + end + end + end +end diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index 0ce06472a53..9664e02c003 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -46,7 +46,7 @@ Rails.application.config.to_prepare do "hierarchy" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "weighted_item_list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO "list" => OpenProject::Common::InplaceEditFields::SelectListComponent, - "user" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "user" => OpenProject::Common::InplaceEditFields::UserSelectListComponent, "version" => OpenProject::Common::InplaceEditFields::VersionSelectListComponent, "calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent } From a3ec1fc15c4e6dfdcfc3b83df9d6fe877645c342 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 17 Feb 2026 14:51:48 +0100 Subject: [PATCH 008/435] Support small spaces for inplaceEditFields by opening inplaceEditFields inside a dialog and truncating the display field --- .../inplace_edit_field_component.html.erb | 29 +++---- .../common/inplace_edit_field_component.rb | 63 ++++++++++++++- ...place_edit_field_dialog_component.html.erb | 35 +++++++++ .../inplace_edit_field_dialog_component.rb | 77 +++++++++++++++++++ .../display_fields/display_field_component.rb | 48 +++++++++++- .../rich_text_area_component.rb | 23 ++++-- .../rich_text_area_component.rb | 31 ++++---- .../select_list_component.rb | 28 ++++--- .../user_select_list_component.rb | 6 +- .../version_select_list_component.rb | 2 +- .../inplace_edit_fields_controller.rb | 12 ++- config/routes.rb | 1 + .../dynamic/inplace-edit.controller.ts | 29 +++++++ .../item_component.html.erb | 25 +++--- .../project_custom_fields/item_component.rb | 7 +- 15 files changed, 341 insertions(+), 75 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_field_dialog_component.html.erb create mode 100644 app/components/open_project/common/inplace_edit_field_dialog_component.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.html.erb b/app/components/open_project/common/inplace_edit_field_component.html.erb index 01251f341c5..a59106cd554 100644 --- a/app/components/open_project/common/inplace_edit_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_component.html.erb @@ -1,20 +1,23 @@ -<%= component_wrapper(tag: :div, class: "op-inplace-edit", data: { test_selector: wrapper_test_selector }) do %> +<%= component_wrapper( + tag: :div, + class: "op-inplace-edit", + uniq_by: wrapper_uniq_by, + data: { + test_selector: wrapper_test_selector, + turbo_stream_target: wrapper_id + } + ) do %> <% if display_field_component.present? && !enforce_edit_mode %> <%= render display_field_component %> <% else %> - <%= primer_form_with( - model:, - url: inplace_edit_field_update_path(model: model.class.name, id: model.id, attribute:), - method: :patch, - data: { turbo_stream: true } - ) do |form| - render_field_component = ->(f) { render edit_field_component(f) } # The render_inline_form method looses context and thus does not know about the `field_component` method - system_arguments = @system_arguments + <%= primer_form_with(**form_options) do |form| + render_field_component = ->(f) { render edit_field_component(f) } # The render_inline_form method looses context and thus does not know about the `field_component` method + system_arguments = @system_arguments - render_inline_form(form) do |f| - f.hidden name: "system_arguments_json", value: system_arguments.to_json - render_field_component.call(f) - end + render_inline_form(form) do |f| + f.hidden name: "system_arguments_json", value: system_arguments.to_json + render_field_component.call(f) + end end %> <% end %> <% end %> diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index dec9b12a8f4..a02f856f661 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -33,13 +33,17 @@ module OpenProject class InplaceEditFieldComponent < ViewComponent::Base include OpTurbo::Streamable - attr_reader :model, :attribute, :enforce_edit_mode + attr_reader :model, :attribute, :enforce_edit_mode, :open_in_dialog, :show_action_buttons, :truncated - def initialize(model:, attribute:, enforce_edit_mode: false, **system_arguments) + def initialize(model:, attribute:, enforce_edit_mode: false, open_in_dialog: false, show_action_buttons: true, + truncated: false, **system_arguments) super() @model = model @attribute = attribute @enforce_edit_mode = enforce_edit_mode + @open_in_dialog = open_in_dialog + @show_action_buttons = show_action_buttons + @truncated = truncated @system_arguments = system_arguments @system_arguments[:id] = system_arguments[:id] || SecureRandom.uuid @system_arguments[:required] ||= required? @@ -55,6 +59,7 @@ module OpenProject form:, attribute:, model:, + show_action_buttons:, **@system_arguments ) end @@ -70,7 +75,8 @@ module OpenProject def display_field_component return nil if display_field_class.nil? - display_field_class.new(model:, attribute:, writable: writable?, **@system_arguments) + additional_args = open_in_dialog? ? dialog_display_arguments : {} + display_field_class.new(model:, attribute:, writable: writable?, truncated:, **@system_arguments.merge(additional_args)) end def wrapper_key @@ -82,8 +88,59 @@ module OpenProject "op-inplace-edit-field" end + def wrapper_uniq_by + "#{@model.class.name.parameterize(separator: '_')}_#{@model.id}_#{@attribute}" + end + + def form_id + @system_arguments[:form_id] + end + + def wrapper_id + @system_arguments[:wrapper_id] + end + + def form_options + options = { + model: @model, + url: inplace_edit_field_update_path( + model: @model.class.name, + id: @model.id, + attribute: @attribute + ), + method: :patch, + data: { turbo_stream: true } + } + + options[:id] = form_id if form_id.present? + options + end + + def open_in_dialog? + @open_in_dialog + end + + def dialog_edit_url + return unless open_in_dialog? + + inplace_edit_field_dialog_path( + model: model.class.name, + id: model.id, + attribute:, + system_arguments_json: @system_arguments.except(:id).to_json + ) + end + private + def dialog_display_arguments + { + dialog_controller_name: "inplace-edit", + dialog_url: dialog_edit_url, + dialog_test_selector: "inplace-edit-dialog-button-#{wrapper_key}" + } + end + def writable? return @writable if defined?(@writable) diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb new file mode 100644 index 00000000000..ae8522e0943 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb @@ -0,0 +1,35 @@ +<%= + render( + Primer::Alpha::Dialog.new( + title: dialog_title, + classes: "Overlay--size-large-portrait", + size: :large, + id: dialog_id + ) + ) do |d| + d.with_header(variant: :large) + d.with_body(classes: "Overlay-body_autocomplete_height") do + render(edit_component) + end + d.with_footer do + component_collection do |footer_collection| + footer_collection.with_component( + Primer::Beta::Button.new(data: { "close-dialog-id": dialog_id }) + ) do + t("button_cancel") + end + footer_collection.with_component( + Primer::Beta::Button.new(scheme: :primary, + type: :submit, + form: form_id, + data: { + test_selector: "save-inplace-edit-field-button", + turbo: true + }) + ) do + t("button_save") + end + end + end + end +%> diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.rb b/app/components/open_project/common/inplace_edit_field_dialog_component.rb new file mode 100644 index 00000000000..7c99b04cc03 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + class InplaceEditFieldDialogComponent < ViewComponent::Base + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(model:, attribute:, system_arguments: {}) + super() + @model = model + @attribute = attribute + @system_arguments = system_arguments + end + + private + + def dialog_title + @system_arguments[:label] || @model.class.human_attribute_name(@attribute) + end + + def dialog_id + model_class = @model.class.name.parameterize(separator: "_") + "inplace-edit-field-dialog--#{model_class}-#{@model.id}--#{@attribute}" + end + + def wrapper_id + "##{dialog_id}" + end + + def form_id + "inplace-edit-field-form-#{dialog_id}" + end + + def edit_component + OpenProject::Common::InplaceEditFieldComponent.new( + model: @model, + attribute: @attribute, + enforce_edit_mode: true, + **@system_arguments.merge( + wrapper_id:, + form_id:, + show_action_buttons: false + ) + ) + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 5dc821258cb..d49262d10d0 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -35,13 +35,14 @@ module OpenProject class DisplayFieldComponent < ViewComponent::Base include OpPrimer::ComponentHelpers - attr_reader :model, :attribute, :writable + attr_reader :model, :attribute, :writable, :truncated - def initialize(model:, attribute:, writable:, **system_arguments) + def initialize(model:, attribute:, writable:, truncated:, **system_arguments) super() @model = model @attribute = attribute @writable = writable + @truncated = truncated @system_arguments = system_arguments end @@ -58,9 +59,48 @@ module OpenProject end def display_field_arguments - @display_field_arguments ||= { + @display_field_arguments ||= if open_in_dialog? + base_arguments.merge(dialog_field_arguments) + else + base_arguments.merge(inline_edit_field_arguments) + end + end + + def open_in_dialog? + @system_arguments[:dialog_controller_name].present? + end + + def base_arguments + { classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable?}", - id: @system_arguments[:id], + id: @system_arguments[:id] + } + end + + def dialog_field_arguments + { + data: { + controller: "inplace-edit async-dialog", + inplace_edit_dialog_url_value: @system_arguments[:dialog_url], + action: "click->inplace-edit#open " \ + "keydown.enter->inplace-edit#open " \ + "keydown.space->inplace-edit#open " \ + "inplace-edit:open-dialog->async-dialog#handleOpenDialog", + test_selector: @system_arguments[:dialog_test_selector] + }, + aria: { + label: [ + I18n.t(:label_edit_x, x: @system_arguments[:label]), + I18n.t(:label_value_x, x: render_display_value) + ].join(", ") + }, + role: "button", + tabindex: 0 + } + end + + def inline_edit_field_arguments + { data: { controller: "inplace-edit", inplace_edit_url_value: edit_url, diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb index 59f7d213df6..676c46fb971 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb @@ -41,19 +41,26 @@ module OpenProject render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do render(Primer::BaseComponent.new(tag: :div, classes: "op-uc-container op-uc-container_reduced-headings -multiline")) do - render_display_value + if field_value.present? + if truncated + render OpenProject::Common::AttributeComponent.new("#{attribute}-truncated-display-field", + attribute, + field_value, + lines: 3) + else + format_text(field_value) + end + else + t("placeholders.default") + end end end end - def render_display_value - value = model.public_send(attribute) + private - if value.present? - format_text(value) - else - t("placeholders.default") - end + def field_value + model.public_send(attribute) end end end diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index 0c75b97bc29..721dbb87455 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -32,17 +32,18 @@ module OpenProject module Common module InplaceEditFields class RichTextAreaComponent < ViewComponent::Base - attr_reader :form, :attribute, :model + attr_reader :form, :attribute, :model, :show_action_buttons def self.display_class DisplayFields::RichTextAreaComponent end - def initialize(form:, attribute:, model:, **system_arguments) + def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments) super() @form = form @attribute = attribute @model = model + @show_action_buttons = show_action_buttons @system_arguments = system_arguments @system_arguments[:classes] = class_names( @system_arguments[:classes], @@ -56,18 +57,20 @@ module OpenProject def call form.rich_text_area(name: attribute, **@system_arguments) - form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| - button_group.submit(name: :reset, - type: :submit, - label: I18n.t(:button_cancel), - scheme: :default, - formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), - formmethod: :get, - test_selector: "op-inplace-edit-field--textarea-cancel") - button_group.submit(name: :submit, - label: I18n.t(:button_save), - scheme: :primary, - test_selector: "op-inplace-edit-field--textarea-save") + if show_action_buttons + form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| + button_group.submit(name: :reset, + type: :submit, + label: I18n.t(:button_cancel), + scheme: :default, + formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), + formmethod: :get, + test_selector: "op-inplace-edit-field--textarea-cancel") + button_group.submit(name: :submit, + label: I18n.t(:button_save), + scheme: :primary, + test_selector: "op-inplace-edit-field--textarea-save") + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index 38ab8b276f1..a6058b5e0d9 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -38,11 +38,12 @@ module OpenProject DisplayFields::SelectListComponent end - def initialize(form:, attribute:, model:, **system_arguments) + def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments) super() @form = form @attribute = attribute @model = model + @show_action_buttons = show_action_buttons @system_arguments = system_arguments @system_arguments[:classes] = class_names( @system_arguments[:classes], @@ -53,6 +54,7 @@ module OpenProject @system_arguments[:autocomplete_options] ||= {} @system_arguments[:autocomplete_options][:model] ||= { id: model.id, name: model.name } @system_arguments[:autocomplete_options][:inputName] ||= attribute + @system_arguments[:autocomplete_options][:wrapper_id] ||= system_arguments[:wrapper_id] if @system_arguments[:autocomplete_options][:focusDirectly].nil? @system_arguments[:autocomplete_options][:focusDirectly] = true @@ -70,16 +72,18 @@ module OpenProject render_autocompleter end - form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| - button_group.submit(name: :reset, - type: :submit, - label: I18n.t(:button_cancel), - scheme: :default, - formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), - formmethod: :get) - button_group.submit(name: :submit, - label: I18n.t(:button_save), - scheme: :primary) + if @show_action_buttons + form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| + button_group.submit(name: :reset, + type: :submit, + label: I18n.t(:button_cancel), + scheme: :default, + formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), + formmethod: :get) + button_group.submit(name: :submit, + label: I18n.t(:button_save), + scheme: :primary) + end end end @@ -94,7 +98,7 @@ module OpenProject # Use fields_for to create the proper context for custom field inputs form.fields_for(:custom_field_values) do |builder| - input_class.new(builder, custom_field:, object: model) + input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options]) end end diff --git a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb index 7f82b639442..f5bb5f79e51 100644 --- a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb @@ -40,10 +40,6 @@ module OpenProject def initialize(form:, attribute:, model:, **system_arguments) super - - unless custom_field? - assign_defaults! - end end private @@ -57,7 +53,7 @@ module OpenProject # Use fields_for to create the proper context for custom field inputs form.fields_for(:custom_field_values) do |builder| - input_class.new(builder, custom_field:, object: model) + input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options]) end end end diff --git a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb index c9ff1c199cf..167330e2877 100644 --- a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb @@ -53,7 +53,7 @@ module OpenProject # Use fields_for to create the proper context for custom field inputs form.fields_for(:custom_field_values) do |builder| - input_class.new(builder, custom_field:, object: model) + input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options]) end end diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index b9df80a865e..b09aba8e8e8 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -33,7 +33,7 @@ class InplaceEditFieldsController < ApplicationController before_action :find_model before_action :set_attribute - no_authorization_required! :edit, :update, :reset + no_authorization_required! :edit, :update, :reset, :dialog def edit replace_via_turbo_stream( @@ -78,6 +78,16 @@ class InplaceEditFieldsController < ApplicationController respond_with_turbo_streams end + def dialog + respond_with_dialog( + OpenProject::Common::InplaceEditFieldDialogComponent.new( + model: @model, + attribute: @attribute, + system_arguments: system_arguments.to_h.symbolize_keys + ) + ) + end + private def find_model diff --git a/config/routes.rb b/config/routes.rb index 5373a93f1f3..dc9c5787126 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1063,6 +1063,7 @@ Rails.application.routes.draw do patch :update, controller: "inplace_edit_fields", action: :update get :reset, controller: "inplace_edit_fields", action: :reset get :edit, controller: "inplace_edit_fields", action: :edit + get :dialog, controller: "inplace_edit_fields", action: :dialog end if OpenProject::Configuration.lookbook_enabled? diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index cba2bf0915f..bb667e03ae9 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -35,9 +35,12 @@ import { renderStreamMessage } from '@hotwired/turbo'; export default class extends Controller { static values = { url: String, + dialogUrl: String, }; declare urlValue:string; + declare dialogUrlValue:string; + declare hasDialogUrlValue:boolean; async request(e:Event):Promise { // Don't trigger edit mode if clicking on a link @@ -59,10 +62,36 @@ export default class extends Controller { } } + open(event:Event) { + const target = event.target as HTMLElement; + + // Check if the event is on an interactive element that should be ignored + if (this.isInteractiveElement(target)) { + // Don't handle this event, let the child element handle it + return; + } + + // Prevent default and dispatch custom event for async-dialog to handle + event.preventDefault(); + this.dispatch('open-dialog', { detail: { url: this.dialogUrlValue } }); + } + submitForm() { const form = this.element.closest('form'); if (form) { form.requestSubmit(); } } + + private isInteractiveElement(element:HTMLElement):boolean { + // Check if the element is or is inside an interactive element. + let current = element; + while (current && current !== this.element) { + if (current.matches('button, a, dialog')) { + return true; + } + current = current.parentElement!; + } + return false; + } } diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb index 7a8f467f53b..952ce45eeac 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb @@ -1,11 +1,13 @@ +<%= render OpenProject::Common::InplaceEditFieldComponent.new( + model: @project, + attribute: @project_custom_field.attribute_name.to_sym, + open_in_dialog: limited_space?, + truncated: limited_space? +) %> -<% if show_inplace_edit_field? %> - <%= render OpenProject::Common::InplaceEditFieldComponent.new( - model: @project, - attribute: @project_custom_field.attribute_name.to_sym - ) %> -<% else %> - <%= + + + +<%# end %> diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb index 7e93f316a10..38596fa411a 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb @@ -46,10 +46,9 @@ module Overviews private - def show_inplace_edit_field? - # TODO: Move outside of this component and pass in instead - @project_custom_field.field_format != "text" || - @project_custom_field.project_custom_field_section&.shown_in_overview_main_area? + def limited_space? + @project_custom_field.field_format == "text" && + @project_custom_field.project_custom_field_section&.shown_in_overview_sidebar? end def allowed_to_edit? From 2558874f34b439f6fee9064ef6d3962b4f3bc716 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 17 Feb 2026 15:36:12 +0100 Subject: [PATCH 009/435] Add BaseClass for input edit fields to avoid duplication --- .../base_field_component.rb | 62 +++++++++++++++++++ .../boolean_input_component.rb | 21 +------ .../link_input_component.rb | 2 - .../rich_text_area_component.rb | 11 +--- .../select_list_component.rb | 28 +-------- .../text_input_component.rb | 16 +---- .../user_select_list_component.rb | 6 -- .../version_select_list_component.rb | 2 - 8 files changed, 69 insertions(+), 79 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/base_field_component.rb diff --git a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb new file mode 100644 index 00000000000..efd4b5012ec --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class BaseFieldComponent < ViewComponent::Base + attr_reader :form, :attribute, :model, :show_action_buttons + + def self.display_class + DisplayFields::DisplayFieldComponent + end + + def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments) + super() + @form = form + @attribute = attribute + @model = model + @show_action_buttons = show_action_buttons + @system_arguments = system_arguments + end + + def custom_field? + attribute.to_s.start_with?("custom_field_") + end + + def custom_field + return @custom_field if defined?(@custom_field) + + @custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i) + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb index d20307d7905..b20c2bfebc1 100644 --- a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb @@ -31,26 +31,7 @@ module OpenProject module Common module InplaceEditFields - class BooleanInputComponent < ViewComponent::Base - attr_reader :form, :attribute, :model - - def self.display_class - DisplayFields::DisplayFieldComponent - end - - def initialize(form:, attribute:, model:, **system_arguments) - super() - @form = form - @attribute = attribute - @model = model - @system_arguments = system_arguments - @system_arguments[:classes] = class_names( - @system_arguments[:classes], - "op-inplace-edit-field--boolean" - ) - @system_arguments[:label] ||= model.class.human_attribute_name(attribute) - end - + class BooleanInputComponent < BaseFieldComponent def call form.check_box name: attribute, data: { controller: "inplace-edit", diff --git a/app/components/open_project/common/inplace_edit_fields/link_input_component.rb b/app/components/open_project/common/inplace_edit_fields/link_input_component.rb index b4301771572..9a0580cb64f 100644 --- a/app/components/open_project/common/inplace_edit_fields/link_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/link_input_component.rb @@ -32,8 +32,6 @@ module OpenProject module Common module InplaceEditFields class LinkInputComponent < TextInputComponent - attr_reader :form, :attribute, :model - def self.display_class DisplayFields::LinkInputComponent end diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index 721dbb87455..cf1ac23bccd 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -31,20 +31,13 @@ module OpenProject module Common module InplaceEditFields - class RichTextAreaComponent < ViewComponent::Base - attr_reader :form, :attribute, :model, :show_action_buttons - + class RichTextAreaComponent < BaseFieldComponent def self.display_class DisplayFields::RichTextAreaComponent end def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments) - super() - @form = form - @attribute = attribute - @model = model - @show_action_buttons = show_action_buttons - @system_arguments = system_arguments + super @system_arguments[:classes] = class_names( @system_arguments[:classes], "op-inplace-edit-field--text-area" diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index a6058b5e0d9..516378c10cf 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -31,30 +31,18 @@ module OpenProject module Common module InplaceEditFields - class SelectListComponent < ViewComponent::Base - attr_reader :form, :attribute, :model - + class SelectListComponent < BaseFieldComponent def self.display_class DisplayFields::SelectListComponent end def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments) - super() - @form = form - @attribute = attribute - @model = model - @show_action_buttons = show_action_buttons - @system_arguments = system_arguments - @system_arguments[:classes] = class_names( - @system_arguments[:classes], - "op-inplace-edit-field--select-list" - ) - @system_arguments[:label] ||= model.class.human_attribute_name(attribute) + super @system_arguments[:autocomplete_options] ||= {} @system_arguments[:autocomplete_options][:model] ||= { id: model.id, name: model.name } @system_arguments[:autocomplete_options][:inputName] ||= attribute - @system_arguments[:autocomplete_options][:wrapper_id] ||= system_arguments[:wrapper_id] + @system_arguments[:autocomplete_options][:wrapper_id] ||= @system_arguments[:wrapper_id] if @system_arguments[:autocomplete_options][:focusDirectly].nil? @system_arguments[:autocomplete_options][:focusDirectly] = true @@ -105,16 +93,6 @@ module OpenProject def render_autocompleter form.autocompleter(name: attribute, **@system_arguments) end - - def custom_field? - attribute.to_s.start_with?("custom_field_") - end - - def custom_field - return @custom_field if defined?(@custom_field) - - @custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i) - end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb index 090187f5868..bc8d5a0c5ee 100644 --- a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb @@ -31,21 +31,7 @@ module OpenProject module Common module InplaceEditFields - class TextInputComponent < ViewComponent::Base - attr_reader :form, :attribute, :model - - def self.display_class - DisplayFields::DisplayFieldComponent - end - - def initialize(form:, attribute:, model:, **system_arguments) - super() - @form = form - @attribute = attribute - @model = model - @system_arguments = system_arguments - end - + class TextInputComponent < BaseFieldComponent def call form.text_field name: attribute, data: { controller: "inplace-edit", diff --git a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb index f5bb5f79e51..434fb0a8e7f 100644 --- a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb @@ -32,16 +32,10 @@ module OpenProject module Common module InplaceEditFields class UserSelectListComponent < SelectListComponent - attr_reader :form, :attribute, :model - def self.display_class DisplayFields::UserSelectListComponent end - def initialize(form:, attribute:, model:, **system_arguments) - super - end - private def render_custom_field_input diff --git a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb index 167330e2877..b3d10925c33 100644 --- a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb @@ -32,8 +32,6 @@ module OpenProject module Common module InplaceEditFields class VersionSelectListComponent < SelectListComponent - attr_reader :form, :attribute, :model - def initialize(form:, attribute:, model:, **system_arguments) super From 1d3338926fdd5f5a74ca1427a07d27f7522cdbf9 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 24 Feb 2026 13:47:36 +0300 Subject: [PATCH 010/435] Add semantic work package ids feature flag --- config/initializers/feature_decisions.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index b02cf565976..718cb6ed326 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -67,3 +67,8 @@ OpenProject::FeatureDecisions.add :new_project_overview, OpenProject::FeatureDecisions.add :scrum_projects, description: "Enables an overhauled version of the backlogs module to " \ "support Scrum projects with a new sprint planning experience. " + +OpenProject::FeatureDecisions.add :semantic_work_package_ids, + description: "Enables the use of semantic work package IDs, " \ + "in the schema -. " \ + "See #71626 for details." From ff84a0b694aa7903af4b0419c1e8beed0774d047 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 24 Feb 2026 18:46:01 +0300 Subject: [PATCH 011/435] Base formation of work packages identifier admin settings form --- ...dentifier_settings_form_component.html.erb | 44 +++++++++++++++++ .../identifier_settings_form_component.rb | 40 ++++++++++++++++ .../work_packages_identifier_controller.rb | 45 +++++++++++++++++ .../work_packages_identifier/show.html.erb | 48 +++++++++++++++++++ config/constants/settings/definition.rb | 9 ++++ config/initializers/menus.rb | 6 +++ config/locales/en.yml | 10 ++++ config/routes.rb | 1 + 8 files changed, 203 insertions(+) create mode 100644 app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb create mode 100644 app/components/work_packages/admin/settings/identifier_settings_form_component.rb create mode 100644 app/controllers/admin/settings/work_packages_identifier_controller.rb create mode 100644 app/views/admin/settings/work_packages_identifier/show.html.erb diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb new file mode 100644 index 00000000000..0d3be15821c --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -0,0 +1,44 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= + settings_primer_form_with(scope: :settings, action: :update, method: :patch) do |f| + render_inline_settings_form(f) do |form| + form.radio_button_group( + name: :work_packages_identifier, + label: I18n.t("settings.work_packages.work_package_identifier"), + required: true + ) + + form.submit + end + end +%> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb new file mode 100644 index 00000000000..c395f6cf0a3 --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +# + +module WorkPackages + module Admin + module Settings + class IdentifierSettingsFormComponent < ApplicationComponent + include OpPrimer::FormHelpers + end + end + end +end diff --git a/app/controllers/admin/settings/work_packages_identifier_controller.rb b/app/controllers/admin/settings/work_packages_identifier_controller.rb new file mode 100644 index 00000000000..537fad2b6ea --- /dev/null +++ b/app/controllers/admin/settings/work_packages_identifier_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin::Settings + class WorkPackagesIdentifierController < ::Admin::SettingsController + before_action :check_feature_flag + + current_menu_item :show do + :work_packages_identifier + end + + private + + def check_feature_flag + render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? + end + end +end diff --git a/app/views/admin/settings/work_packages_identifier/show.html.erb b/app/views/admin/settings/work_packages_identifier/show.html.erb new file mode 100644 index 00000000000..d0ae4254bd6 --- /dev/null +++ b/app/views/admin/settings/work_packages_identifier/show.html.erb @@ -0,0 +1,48 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<% html_title t(:label_administration), t(:label_work_package_plural), t(:label_identifier) -%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_identifier) } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_work_packages_identifier_path, text: t(:label_work_package_plural), skip_for_mobile: true }, + t(:label_identifier)] + ) + header.with_description do + t("admin.settings.work_packages_identifier.page_header.description") + end + end +%> + +<%= render(WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new) %> diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 968e5fe6eff..664989a9815 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -1275,6 +1275,15 @@ module Settings work_packages_bulk_request_limit: { default: 10 }, + work_packages_identifier: { + description: "Defines how work packages are identified in the UI (e.g. in links and titles). " \ + "The 'numeric' option uses the work package numerical ID, " \ + "while 'alphanumeric' uses the project identifier and the work package ID separated by a dash " \ + "(e.g. 'PROJA-123').", + format: :string, + allowed: %w[numeric alphanumeric], + default: "numeric" + }, work_package_list_default_highlighted_attributes: { default: ["status", "priority", "due_date"], allowed: -> { diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index f4f03e9a517..d534f00cf41 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -408,6 +408,12 @@ Redmine::MenuManager.map :admin_menu do |menu| caption: IssuePriority.model_name.human(count: :other), parent: :admin_work_packages + menu.push :work_packages_identifier, + { controller: "/admin/settings/work_packages_identifier", action: :show }, + if: ->(_) { OpenProject::FeatureDecisions.semantic_work_package_ids_active? && User.current.admin? }, + caption: :label_identifier, + parent: :admin_work_packages + menu.push :progress_tracking, { controller: "/admin/settings/progress_tracking", action: :show }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 6267dd4593d..d52663dd2ad 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -208,6 +208,9 @@ en:

Hello,

A new project has been created: projectValue:name

Thank you

+ work_packages_identifier: + page_header: + description: Choose between basic numerical work packages IDs or project-specific ones that prepend the project identifier to the work package ID. workflows: tabs: default_transitions: "Default transitions" @@ -4757,6 +4760,12 @@ en: setting_welcome_text: "Welcome block text" setting_welcome_title: "Welcome block title" setting_welcome_on_homescreen: "Display welcome block on homescreen" + setting_work_packages_identifier_numeric: Instance-wide numerical sequence (default) + setting_work_packages_identifier_numeric_caption: > + Every work package gets a sequential number starting with 1 and incremented with every new one. The numbers are unique within this instance so they remain the same even if work packages are moved between projects. + setting_work_packages_identifier_alphanumeric: Project-based alphanumerical identifiers + setting_work_packages_identifier_alphanumeric_caption: > + Every project has a unique identifier that is prefixed to the work package ID. If a work package moved to another project, a new identifier is generated but the old one continues to function. setting_work_package_list_default_highlighting_mode: "Default highlighting mode" setting_work_package_list_default_highlighted_attributes: "Default inline highlighted attributes" setting_working_days: "Working days" @@ -4959,6 +4968,7 @@ en: section_work_week: "Work week" section_holidays_and_closures: "Holidays and closures" work_packages: + work_package_identifier: "Work package identifier" not_allowed_text: "You do not have the necessary permissions to view this page." activities: enable_internal_comments: "Enable internal comments" diff --git a/config/routes.rb b/config/routes.rb index ef6e89695a5..2b0d6bfe89f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -660,6 +660,7 @@ Rails.application.routes.draw do # It is important to have this named something else than "work_packages". # Otherwise the angular ui-router will also recognize that as a WorkPackage page and apply according classes. resource :work_packages_general, controller: "/admin/settings/work_packages_general", only: %i[show update] + resource :work_packages_identifier, controller: "/admin/settings/work_packages_identifier", only: %i[show update] resources :work_package_priorities, except: [:show] do member do put :move From 08f1e2992a6dc1f68b37b10e809034b5fe31dc39 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 17:07:40 +0300 Subject: [PATCH 012/435] Add identifier admin settings UI with autofix preview Adds the admin settings page for choosing between numeric and project-based alphanumeric work package identifiers (#72461): - ProjectHandleSuggestionGenerator service that scans projects for identifiers that don't meet alphanumeric handle requirements and generates uppercase acronym suggestions (e.g. "PROJ" from "My Project") - IdentifierAutofixPreviewComponent: Primer BorderBox table showing the first 5 problematic projects with their suggestions and a "... N more" footer row - work-packages-identifier Stimulus controller that shows/hides the autofix section and updates the submit button label on radio change - Updated IdentifierSettingsFormComponent wired to the Stimulus controller, showing the warning banner and preview table when "Project-based alphanumerical identifiers" is selected --- ...ntifier_autofix_preview_component.html.erb | 100 ++++++++++ .../identifier_autofix_preview_component.rb | 62 ++++++ ...dentifier_settings_form_component.html.erb | 48 ++++- .../identifier_settings_form_component.rb | 19 ++ .../project_handle_suggestion_generator.rb | 122 ++++++++++++ config/locales/en.yml | 18 ++ .../work-packages-identifier.controller.ts | 70 +++++++ ...entifier_autofix_preview_component_spec.rb | 151 ++++++++++++++ ...roject_handle_suggestion_generator_spec.rb | 187 ++++++++++++++++++ 9 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb create mode 100644 app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb create mode 100644 app/services/work_packages/project_handle_suggestion_generator.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts create mode 100644 spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb create mode 100644 spec/services/work_packages/project_handle_suggestion_generator_spec.rb diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb new file mode 100644 index 00000000000..a69586c3475 --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb @@ -0,0 +1,100 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= + render(border_box_container(mb: 3)) do |component| + component.with_header(font_weight: :bold) do + flex_layout do |header| + header.with_column(flex: 1) do + render(Primer::Beta::Text.new(font_weight: :semibold)) do + I18n.t("admin.settings.work_packages_identifier.box_header.label_project") + end + end + header.with_column(flex: 1) do + render(Primer::Beta::Text.new(font_weight: :semibold)) do + I18n.t("admin.settings.work_packages_identifier.box_header.label_previous_identifier") + end + end + header.with_column(flex: 1) do + render(Primer::Beta::Text.new(font_weight: :semibold)) do + I18n.t("admin.settings.work_packages_identifier.box_header.label_autofixed_suggestion") + end + end + header.with_column(flex: 1) do + render(Primer::Beta::Text.new(font_weight: :semibold)) do + I18n.t("admin.settings.work_packages_identifier.box_header.label_example_work_package_id") + end + end + end + end + + displayed.each do |entry| + component.with_row do + flex_layout(align_items: :center) do |row| + row.with_column(flex: 1) do + render(Primer::Beta::Link.new(href: project_path(entry[:project]))) do + entry[:project].name + end + end + row.with_column(flex: 1) do + flex_layout(direction: :column) do |col| + col.with_row do + render(Primer::Beta::Text.new) { entry[:current_identifier] } + end + col.with_row do + render(Primer::Beta::Text.new(color: :danger, font_size: :small)) do + error_label(entry[:error_reason]) + end + end + end + end + row.with_column(flex: 1) do + render(Primer::Beta::Text.new) { entry[:suggested_handle] } + end + row.with_column(flex: 1) do + render(Primer::Beta::Text.new) { "#{entry[:suggested_handle]}-1" } + end + end + end + end + + if remaining_count > 0 + component.with_row do + render(Primer::Beta::Text.new(color: :muted)) do + I18n.t( + "admin.settings.work_packages_identifier.autofix_preview.remaining_projects", + count: remaining_count + ) + end + end + end + end +%> diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb new file mode 100644 index 00000000000..a8267629b97 --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Admin + module Settings + class IdentifierAutofixPreviewComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + DISPLAY_COUNT = 5 + + # projects_data: array of hashes from ProjectHandleSuggestionGenerator + # Each hash: { project:, current_identifier:, suggested_handle:, error_reason: } + def initialize(projects_data:) + super() + @displayed = projects_data.first(DISPLAY_COUNT) + @remaining_count = [projects_data.size - DISPLAY_COUNT, 0].max + end + + private + + attr_reader :displayed, :remaining_count + + def error_label(error_reason) + case error_reason + when :too_long + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_too_long") + when :special_characters + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_special_characters") + end + end + end + end + end +end diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index 0d3be15821c..652b7b1e714 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -30,15 +30,57 @@ %> <%= - settings_primer_form_with(scope: :settings, action: :update, method: :patch) do |f| + settings_primer_form_with( + scope: :settings, action: :update, method: :patch, + data: { + controller: "admin--work-packages-identifier", + admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects?, + admin__work_packages_identifier_autofix_label_value: t("admin.settings.work_packages_identifier.button_autofix"), + admin__work_packages_identifier_save_label_value: t("button_save") + } + ) do |f| render_inline_settings_form(f) do |form| form.radio_button_group( name: :work_packages_identifier, label: I18n.t("settings.work_packages.work_package_identifier"), - required: true + required: true, + button_options: { + data: { action: "change->admin--work-packages-identifier#handleChange" } + } ) - form.submit + form.html_content do + tag.div( + hidden: !show_autofix_section?, + data: { admin__work_packages_identifier_target: "autofixSection" } + ) do + concat( + render( + Primer::Alpha::Banner.new( + scheme: :warning, + mb: 3 + ) + ) do + t( + "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", + project_count: projects_data.size + ) + end + ) + concat( + render( + WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent.new( + projects_data: + ) + ) + ) + end + end + + form.submit( + label: show_autofix_section? ? t("admin.settings.work_packages_identifier.button_autofix") : t("button_save"), + data: { admin__work_packages_identifier_target: "submitButton" } + ) end end %> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index c395f6cf0a3..16dbbab1d27 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -34,6 +34,25 @@ module WorkPackages module Settings class IdentifierSettingsFormComponent < ApplicationComponent include OpPrimer::FormHelpers + + def initialize + super + @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call + end + + def has_problematic_projects? + @projects_data.any? + end + + def projects_data + @projects_data + end + + private + + def show_autofix_section? + Setting[:work_packages_identifier] == "alphanumeric" && has_problematic_projects? + end end end end diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb new file mode 100644 index 00000000000..f830f77ce83 --- /dev/null +++ b/app/services/work_packages/project_handle_suggestion_generator.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + # Scans all projects for identifiers that do not meet alphanumeric handle + # requirements and generates a short uppercase suggestion for each. + # + # A "problematic" identifier is one that: + # - contains any character outside [a-zA-Z0-9], or + # - is longer than 10 characters + # + # This service is designed so the data source can be swapped out once the + # project_handles data model exists (replace Project.all scan with a join on + # ProjectHandle where current: true). + class ProjectHandleSuggestionGenerator + HANDLE_MAX_LENGTH = 10 + VALID_HANDLE_PATTERN = /\A[a-zA-Z0-9]{1,10}\z/ + + # Returns an array of hashes for projects with problematic identifiers: + # [{ project:, current_identifier:, suggested_handle:, error_reason: }, ...] + # + # error_reason is one of: :too_long, :special_characters + def self.call + new.call + end + + def call + projects = Project.all.to_a + problematic = projects.select { |p| problematic?(p.identifier) } + generate_suggestions(problematic) + end + + private + + def problematic?(identifier) + return false if identifier.blank? + + identifier.length > HANDLE_MAX_LENGTH || identifier.match?(/[^a-zA-Z0-9]/) + end + + def error_reason(identifier) + if identifier.length > HANDLE_MAX_LENGTH + :too_long + else + :special_characters + end + end + + def generate_suggestions(projects) + used_handles = Set.new + + projects.map do |project| + base = handle_from_name(project.name) + handle = unique_handle(base, used_handles) + used_handles << handle + + { + project:, + current_identifier: project.identifier, + suggested_handle: handle, + error_reason: error_reason(project.identifier) + } + end + end + + # Derives a short uppercase handle from the project name by taking the + # first letter of each word (acronym style). + # e.g. "Flight Planning Algorithm" => "FPA" + # "Fly & Sky" => "FS" + # "arcanos-web" => "AW" (falls back when name is blank) + def handle_from_name(name) + words = name.to_s.scan(/[a-zA-Z0-9]+/) + return "P" if words.empty? + + acronym = words.map { |w| w[0] }.join.upcase # rubocop:disable Rails/Pluck + acronym.slice(0, HANDLE_MAX_LENGTH) + end + + def unique_handle(base, used_handles) + candidate = base + return candidate unless used_handles.include?(candidate) + + counter = 2 + loop do + suffix = counter.to_s + candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}" + break unless used_handles.include?(candidate) + + counter += 1 + end + + candidate + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index d52663dd2ad..41770122c17 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -211,6 +211,24 @@ en: work_packages_identifier: page_header: description: Choose between basic numerical work packages IDs or project-specific ones that prepend the project identifier to the work package ID. + banner: + existing_identifiers_notice: > + Existing identifiers for %{project_count} projects don't meet requirements for project-based alphanumerical identifiers. + OpenProject can automatically update these so that they are valid as in the examples below. + Click on 'Autofix and save' to update identifiers for all projects in this manner and enable project-based alphanumerical identifiers. + box_header: + label_project: Project + label_previous_identifier: Previous identifier + label_autofixed_suggestion: Autofixed suggestion + label_example_work_package_id: Example work package ID + autofix_preview: + error_too_long: Has to be fewer than 10 characters + error_special_characters: Special characters not allowed + remaining_projects: + one: "... 1 more project" + other: "... %{count} more projects" + button_autofix: Autofix and save + workflows: tabs: default_transitions: "Default transitions" diff --git a/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts new file mode 100644 index 00000000000..15168b3f5e1 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts @@ -0,0 +1,70 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class WorkPackagesIdentifierController extends Controller { + static values = { + hasProblematicProjects: Boolean, + autofixLabel: String, + saveLabel: String, + }; + + static targets = ['autofixSection', 'submitButton']; + + declare readonly hasProblematicProjectsValue:boolean; + declare readonly autofixLabelValue:string; + declare readonly saveLabelValue:string; + + declare readonly autofixSectionTarget:HTMLElement; + declare readonly submitButtonTarget:HTMLButtonElement; + + connect() { + this.updateVisibility(); + } + + handleChange() { + this.updateVisibility(); + } + + private updateVisibility() { + const showAutofix = this.isAlphanumericSelected() && this.hasProblematicProjectsValue; + + this.autofixSectionTarget.hidden = !showAutofix; + this.submitButtonTarget.textContent = showAutofix ? this.autofixLabelValue : this.saveLabelValue; + } + + private isAlphanumericSelected():boolean { + const checked = this.element.querySelector( + 'input[name="settings[work_packages_identifier]"]:checked', + ); + return checked?.value === 'alphanumeric'; + } +} diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb new file mode 100644 index 00000000000..24eaea8bf2d --- /dev/null +++ b/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent, type: :component do + include OpenProject::StaticRouting::UrlHelpers + + def build_entry(name:, identifier:, handle:, error_reason:) + project = instance_double(Project, name:, id: rand(1..9999), to_param: identifier) + { + project:, + current_identifier: identifier, + suggested_handle: handle, + error_reason: + } + end + + let(:entry_special_chars) do + build_entry(name: "Flight Planning", identifier: "flight-planning", handle: "FP", error_reason: :special_characters) + end + let(:entry_too_long) do + build_entry(name: "Very Long Name Project", identifier: "verylongnameproject", handle: "VLNP", error_reason: :too_long) + end + + subject(:component) { described_class.new(projects_data: projects_data) } + + context "with fewer than 5 projects" do + let(:projects_data) { [entry_special_chars, entry_too_long] } + + it "renders all entries without a footer" do + render_inline(component) + expect(page).to have_text("Flight Planning") + expect(page).to have_text("Very Long Name Project") + expect(page).to have_no_text("more project") + end + + it "shows the previous identifier" do + render_inline(component) + expect(page).to have_text("flight-planning") + expect(page).to have_text("verylongnameproject") + end + + it "shows the suggested handle" do + render_inline(component) + expect(page).to have_text("FP") + expect(page).to have_text("VLNP") + end + + it "shows an example work package ID with -1 suffix" do + render_inline(component) + expect(page).to have_text("FP-1") + expect(page).to have_text("VLNP-1") + end + + it "shows the special characters error caption" do + render_inline(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_special_characters")) + end + + it "shows the too long error caption" do + render_inline(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_too_long")) + end + end + + context "with exactly 5 projects" do + let(:projects_data) do + Array.new(5) do |i| + build_entry(name: "Project #{i}", identifier: "proj-#{i}", handle: "P#{i}", error_reason: :special_characters) + end + end + + it "renders all 5 entries without a footer" do + render_inline(component) + expect(page).to have_no_text("more project") + end + end + + context "with more than 5 projects" do + let(:projects_data) do + Array.new(8) do |i| + build_entry(name: "Project #{i}", identifier: "proj-#{i}", handle: "P#{i}X", error_reason: :special_characters) + end + end + + it "renders only the first 5 entries" do + render_inline(component) + expect(page).to have_text("Project 0") + expect(page).to have_text("Project 4") + expect(page).to have_no_text("Project 5") + end + + it "shows a footer with the remaining count" do + render_inline(component) + expect(page).to have_text("3 more projects") + end + end + + context "with exactly 6 projects" do + let(:projects_data) do + Array.new(6) do |i| + build_entry(name: "Project #{i}", identifier: "proj-#{i}", handle: "P#{i}X", error_reason: :special_characters) + end + end + + it "shows '1 more project' (singular)" do + render_inline(component) + expect(page).to have_text("1 more project") + end + end + + context "with column headers" do + let(:projects_data) { [entry_special_chars] } + + it "renders all four column headers" do + render_inline(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.box_header.label_project")) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.box_header.label_previous_identifier")) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.box_header.label_autofixed_suggestion")) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.box_header.label_example_work_package_id")) + end + end +end diff --git a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb new file mode 100644 index 00000000000..b17ca45a9c3 --- /dev/null +++ b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do + subject(:generator) { described_class.new } + + # Access private methods for unit testing the algorithm + let(:private_gen) { described_class.new } + + describe ".call" do + context "when there are no problematic project identifiers" do + before do + allow(Project).to receive(:all).and_return([ + instance_double(Project, identifier: "valid", name: "Valid Project") + ]) + end + + it "returns an empty array" do + expect(described_class.call).to be_empty + end + end + + context "when projects have identifiers that are too long" do + let(:project) { instance_double(Project, identifier: "verylongidentifier", name: "Very Long Identifier") } + + before do + allow(Project).to receive(:all).and_return([project]) + end + + it "returns a suggestion entry for the project" do + result = described_class.call + expect(result.size).to eq(1) + expect(result.first[:project]).to eq(project) + expect(result.first[:current_identifier]).to eq("verylongidentifier") + expect(result.first[:error_reason]).to eq(:too_long) + expect(result.first[:suggested_handle]).to be_present + expect(result.first[:suggested_handle].length).to be <= 10 + end + end + + context "when projects have identifiers with special characters" do + let(:project) { instance_double(Project, identifier: "fly-sky", name: "Fly Sky") } + + before do + allow(Project).to receive(:all).and_return([project]) + end + + it "returns a suggestion entry with error_reason :special_characters" do + result = described_class.call + expect(result.size).to eq(1) + expect(result.first[:error_reason]).to eq(:special_characters) + end + end + + context "when multiple projects would generate conflicting handles" do + let(:project_sc1) { instance_double(Project, identifier: "sc-app", name: "Stream Communicator") } + let(:project_sc2) { instance_double(Project, identifier: "stream-channel", name: "Stream Channel") } + + before do + allow(Project).to receive(:all).and_return([project_sc1, project_sc2]) + end + + it "generates unique handles for each project" do + result = described_class.call + handles = result.pluck(:suggested_handle) + expect(handles.uniq.size).to eq(handles.size) + end + + it "appends a numeric suffix to resolve conflicts" do + result = described_class.call + handles = result.pluck(:suggested_handle) + # One will be "SC" and the other "SC2" + expect(handles).to include("SC") + expect(handles.any? { |h| h.match?(/\ASC\d+\z/) }).to be true + end + end + + context "with a mix of valid and problematic identifiers" do + let(:valid_project) { instance_double(Project, identifier: "valid", name: "Valid") } + let(:bad_project) { instance_double(Project, identifier: "too-long-id", name: "Too Long Id") } + + before do + allow(Project).to receive(:all).and_return([valid_project, bad_project]) + end + + it "only includes problematic projects in the result" do + result = described_class.call + expect(result.size).to eq(1) + expect(result.first[:project]).to eq(bad_project) + end + end + end + + describe "handle generation from project name" do + { + "Flight Planning Algorithm" => "FPA", + "Fly & Sky" => "FS", + "Social media marketing" => "SMM", + "Arcanos Mobile Web App" => "AMWA", + "Flight Planning Training" => "FPT", + "A B C D E F G H I J K" => "ABCDEFGHIJ" + }.each do |project_name, expected_handle| + it "generates '#{expected_handle}' from '#{project_name}'" do + project = instance_double(Project, identifier: "bad-id", name: project_name) + allow(Project).to receive(:all).and_return([project]) + result = described_class.call + expect(result.first[:suggested_handle]).to eq(expected_handle) + end + end + end + + describe "problematic identifier detection" do + valid_identifiers = %w[valid VALID123 abc arcanosweb] + problematic_identifiers = ["verylongidentifier", "12345678901", "arcanos-web", "fly_sky", "fly&sky"] + + valid_identifiers.each do |identifier| + it "does not flag '#{identifier}' as problematic" do + project = instance_double(Project, identifier:, name: "Test Project") + allow(Project).to receive(:all).and_return([project]) + expect(described_class.call).to be_empty + end + end + + problematic_identifiers.each do |identifier| + it "flags '#{identifier}' as problematic" do + project = instance_double(Project, identifier:, name: "Test Project") + allow(Project).to receive(:all).and_return([project]) + expect(described_class.call).not_to be_empty + end + end + end + + describe "error reason assignment" do + context "when identifier is too long" do + it "assigns :too_long" do + project = instance_double(Project, identifier: "verylongidentifier", name: "Test") + allow(Project).to receive(:all).and_return([project]) + expect(described_class.call.first[:error_reason]).to eq(:too_long) + end + end + + context "when identifier contains special characters" do + it "assigns :special_characters" do + project = instance_double(Project, identifier: "my-project", name: "Test") + allow(Project).to receive(:all).and_return([project]) + expect(described_class.call.first[:error_reason]).to eq(:special_characters) + end + end + + context "when identifier is both too long and has special chars" do + it "assigns :too_long (length takes priority)" do + project = instance_double(Project, identifier: "my-very-long-identifier", name: "Test") + allow(Project).to receive(:all).and_return([project]) + expect(described_class.call.first[:error_reason]).to eq(:too_long) + end + end + end +end From 8ca8e9d8d1f47a0a4613e0750a03d9b9b0093808 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 17:26:06 +0300 Subject: [PATCH 013/435] Refactor: SQL query, full-width layout, docs, FIXME markers - ProjectHandleSuggestionGenerator: replace Project.all.to_a + Ruby-side filter with a SQL-filtered, column-minimal query (select :id/:name/:identifier, WHERE length > 10 OR non-alphanumeric). Adds FIXME(project_handles) markers showing the exact ProjectHandle query to swap in once the data model exists. Adds inline docs explaining the unique_handle collision-resolution algorithm. - IdentifierSettingsFormComponent template: restructure for full-width banner and table. The form (radio buttons only) stays inside the 680px settings_primer_form_with wrapper and gets id="wp-identifier-settings-form". The autofix section (banner + table) is a sibling div outside the wrapper. The submit button lives in its own 680px wrapper and links back to the form via the HTML5 form="wp-identifier-settings-form" attribute. --- ...dentifier_settings_form_component.html.erb | 128 ++++++++++-------- .../project_handle_suggestion_generator.rb | 91 ++++++++----- ...roject_handle_suggestion_generator_spec.rb | 114 ++++++++-------- 3 files changed, 188 insertions(+), 145 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index 652b7b1e714..633d5bd2609 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -29,58 +29,80 @@ ++# %> -<%= - settings_primer_form_with( - scope: :settings, action: :update, method: :patch, - data: { - controller: "admin--work-packages-identifier", - admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects?, - admin__work_packages_identifier_autofix_label_value: t("admin.settings.work_packages_identifier.button_autofix"), - admin__work_packages_identifier_save_label_value: t("button_save") - } - ) do |f| - render_inline_settings_form(f) do |form| - form.radio_button_group( - name: :work_packages_identifier, - label: I18n.t("settings.work_packages.work_package_identifier"), - required: true, - button_options: { - data: { action: "change->admin--work-packages-identifier#handleChange" } - } - ) +<%# + Layout: + [outer div — Stimulus controller, full width] + [settings_primer_form_with — 680px wrapper, radio buttons only] +
...
+ [full-width autofix section — banner + table, Stimulus target] + [op-admin-settings-form-wrapper — 680px, submit button linked via HTML5 form attr] - form.html_content do - tag.div( - hidden: !show_autofix_section?, - data: { admin__work_packages_identifier_target: "autofixSection" } - ) do - concat( - render( - Primer::Alpha::Banner.new( - scheme: :warning, - mb: 3 - ) - ) do - t( - "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", - project_count: projects_data.size - ) - end - ) - concat( - render( - WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent.new( - projects_data: - ) - ) - ) - end - end - - form.submit( - label: show_autofix_section? ? t("admin.settings.work_packages_identifier.button_autofix") : t("button_save"), - data: { admin__work_packages_identifier_target: "submitButton" } - ) - end - end + The submit button sits outside the
element but is linked back to it via + the HTML5 `form="wp-identifier-settings-form"` attribute, so clicking it still + submits the form. This allows the banner + table to be full-width while keeping + the radio buttons and submit button constrained to the standard 680px admin width. %> + +<%= tag.div( + data: { + controller: "admin--work-packages-identifier", + admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects?, + admin__work_packages_identifier_autofix_label_value: t("admin.settings.work_packages_identifier.button_autofix"), + admin__work_packages_identifier_save_label_value: t("button_save") + } + ) do %> + <%# Constrained-width form: radio buttons only (no submit inside the form element) %> + <%= + settings_primer_form_with( + scope: :settings, action: :update, method: :patch, + html: { id: "wp-identifier-settings-form" } + ) do |f| + render_inline_settings_form(f) do |form| + form.radio_button_group( + name: :work_packages_identifier, + label: I18n.t("settings.work_packages.work_package_identifier"), + required: true, + button_options: { + data: { action: "change->admin--work-packages-identifier#handleChange" } + } + ) + end + end + %> + + <%# Full-width autofix section — outside the 680px settings_primer_form_with wrapper %> + <%= tag.div( + hidden: !show_autofix_section?, + data: { admin__work_packages_identifier_target: "autofixSection" } + ) do %> + <%= + render(Primer::Alpha::Banner.new(scheme: :warning, mb: 3)) do + t( + "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", + project_count: projects_data.size + ) + end + %> + <%= + render( + WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent.new(projects_data:) + ) + %> + <% end %> + + <%# Submit button: constrained to 680px, linked to the form via HTML5 `form` attribute %> +
+ <%= + render( + Primer::Beta::Button.new( + scheme: :primary, + type: :submit, + form: "wp-identifier-settings-form", + data: { admin__work_packages_identifier_target: "submitButton" } + ) + ) do + show_autofix_section? ? t("admin.settings.work_packages_identifier.button_autofix") : t("button_save") + end + %> +
+<% end %> diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb index f830f77ce83..790e1b7f6e5 100644 --- a/app/services/work_packages/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/project_handle_suggestion_generator.rb @@ -29,42 +29,55 @@ #++ module WorkPackages - # Scans all projects for identifiers that do not meet alphanumeric handle - # requirements and generates a short uppercase suggestion for each. + # Scans projects for identifiers that do not meet alphanumeric handle + # requirements (too long or containing non-alphanumeric characters) and + # generates a short uppercase acronym suggestion for each one. # # A "problematic" identifier is one that: # - contains any character outside [a-zA-Z0-9], or - # - is longer than 10 characters + # - is longer than HANDLE_MAX_LENGTH (10) characters # - # This service is designed so the data source can be swapped out once the - # project_handles data model exists (replace Project.all scan with a join on - # ProjectHandle where current: true). + # The suggestion is derived from the project name: taking the first letter of + # each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two + # projects produce the same acronym, a numeric suffix resolves the collision + # ("SC", "SC2", "SC3", …). + # + # FIXME(project_handles): This class currently reads from the existing + # Project#identifier column. Once the project_handles data model is available, + # replace #call with: + # + # ProjectHandle + # .select("project_handles.handle AS identifier, projects.id, projects.name") + # .joins(:project) + # .where(current: true) + # .where("length(handle) > ? OR handle ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") + # .to_a + # .then { |problematic| generate_suggestions(problematic) } + # + # The :current boolean on ProjectHandle marks the live handle; old handles are + # retained so that existing URLs continue to resolve. class ProjectHandleSuggestionGenerator HANDLE_MAX_LENGTH = 10 - VALID_HANDLE_PATTERN = /\A[a-zA-Z0-9]{1,10}\z/ - # Returns an array of hashes for projects with problematic identifiers: - # [{ project:, current_identifier:, suggested_handle:, error_reason: }, ...] - # - # error_reason is one of: :too_long, :special_characters + # @return [Array] one entry per project with a problematic identifier: + # { project:, current_identifier:, suggested_handle:, error_reason: } + # error_reason is :too_long or :special_characters def self.call new.call end def call - projects = Project.all.to_a - problematic = projects.select { |p| problematic?(p.identifier) } - generate_suggestions(problematic) + # FIXME(project_handles): Swap Project query for ProjectHandle query (see class doc above). + # Only select the three columns we need to avoid loading large text/JSON attributes. + Project + .select(:id, :name, :identifier) + .where("length(identifier) > ? OR identifier ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") + .to_a + .then { |problematic| generate_suggestions(problematic) } end private - def problematic?(identifier) - return false if identifier.blank? - - identifier.length > HANDLE_MAX_LENGTH || identifier.match?(/[^a-zA-Z0-9]/) - end - def error_reason(identifier) if identifier.length > HANDLE_MAX_LENGTH :too_long @@ -73,11 +86,14 @@ module WorkPackages end end + # Builds the suggestion list for a set of problematic projects. + # Handles are generated in iteration order; duplicates are resolved in-place + # so the final list is guaranteed to contain no two identical handles. def generate_suggestions(projects) used_handles = Set.new projects.map do |project| - base = handle_from_name(project.name) + base = handle_from_name(project.name) handle = unique_handle(base, used_handles) used_handles << handle @@ -91,10 +107,11 @@ module WorkPackages end # Derives a short uppercase handle from the project name by taking the - # first letter of each word (acronym style). - # e.g. "Flight Planning Algorithm" => "FPA" - # "Fly & Sky" => "FS" - # "arcanos-web" => "AW" (falls back when name is blank) + # first letter of each word (acronym style): + # "Flight Planning Algorithm" → "FPA" + # "Fly & Sky" → "FS" + # Falls back to "P" when the name yields no alphanumeric words. + # Result is truncated to HANDLE_MAX_LENGTH characters. def handle_from_name(name) words = name.to_s.scan(/[a-zA-Z0-9]+/) return "P" if words.empty? @@ -103,20 +120,32 @@ module WorkPackages acronym.slice(0, HANDLE_MAX_LENGTH) end + # Ensures the returned handle is unique within the current batch by appending + # an incrementing numeric suffix when the base acronym is already taken. + # + # Examples (HANDLE_MAX_LENGTH = 10): + # unique_handle("SC", Set["SC"]) → "SC2" + # unique_handle("SC", Set["SC", "SC2"]) → "SC3" + # unique_handle("ABCDEFGHIJ", Set["ABCDEFGHIJ"]) → "ABCDEFGHI2" + # (base is trimmed so base + suffix ≤ HANDLE_MAX_LENGTH) + # + # @param base [String] the acronym to start from (already ≤ HANDLE_MAX_LENGTH) + # @param used_handles [Set] handles already assigned in this batch + # @return [String] a unique handle ≤ HANDLE_MAX_LENGTH def unique_handle(base, used_handles) - candidate = base - return candidate unless used_handles.include?(candidate) + # Fast path: acronym is unique, no suffix needed. + return base unless used_handles.include?(base) + # Slow path: append "2", "3", … trimming the base as needed so the result + # never exceeds HANDLE_MAX_LENGTH characters. counter = 2 loop do - suffix = counter.to_s + suffix = counter.to_s candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}" - break unless used_handles.include?(candidate) + break candidate unless used_handles.include?(candidate) counter += 1 end - - candidate end end end diff --git a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb index b17ca45a9c3..0d14e5169be 100644 --- a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb @@ -31,30 +31,26 @@ require "rails_helper" RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do - subject(:generator) { described_class.new } - - # Access private methods for unit testing the algorithm - let(:private_gen) { described_class.new } + # Stub the SQL query chain used by #call. + # The WHERE clause in production filters out valid identifiers; tests control + # what the query "returns" by mocking the end of the chain. + def stub_query(projects) + allow(Project).to receive_message_chain(:select, :where, :to_a).and_return(projects) # rubocop:disable RSpec/MessageChain + end describe ".call" do - context "when there are no problematic project identifiers" do - before do - allow(Project).to receive(:all).and_return([ - instance_double(Project, identifier: "valid", name: "Valid Project") - ]) - end + context "when the query returns no projects (all identifiers are valid)" do + before { stub_query([]) } it "returns an empty array" do expect(described_class.call).to be_empty end end - context "when projects have identifiers that are too long" do + context "when the query returns a project with a too-long identifier" do let(:project) { instance_double(Project, identifier: "verylongidentifier", name: "Very Long Identifier") } - before do - allow(Project).to receive(:all).and_return([project]) - end + before { stub_query([project]) } it "returns a suggestion entry for the project" do result = described_class.call @@ -67,12 +63,10 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do end end - context "when projects have identifiers with special characters" do + context "when the query returns a project with a special-character identifier" do let(:project) { instance_double(Project, identifier: "fly-sky", name: "Fly Sky") } - before do - allow(Project).to receive(:all).and_return([project]) - end + before { stub_query([project]) } it "returns a suggestion entry with error_reason :special_characters" do result = described_class.call @@ -85,9 +79,7 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do let(:project_sc1) { instance_double(Project, identifier: "sc-app", name: "Stream Communicator") } let(:project_sc2) { instance_double(Project, identifier: "stream-channel", name: "Stream Channel") } - before do - allow(Project).to receive(:all).and_return([project_sc1, project_sc2]) - end + before { stub_query([project_sc1, project_sc2]) } it "generates unique handles for each project" do result = described_class.call @@ -98,26 +90,11 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do it "appends a numeric suffix to resolve conflicts" do result = described_class.call handles = result.pluck(:suggested_handle) - # One will be "SC" and the other "SC2" + # "Stream Communicator" → "SC", "Stream Channel" → "SC2" expect(handles).to include("SC") expect(handles.any? { |h| h.match?(/\ASC\d+\z/) }).to be true end end - - context "with a mix of valid and problematic identifiers" do - let(:valid_project) { instance_double(Project, identifier: "valid", name: "Valid") } - let(:bad_project) { instance_double(Project, identifier: "too-long-id", name: "Too Long Id") } - - before do - allow(Project).to receive(:all).and_return([valid_project, bad_project]) - end - - it "only includes problematic projects in the result" do - result = described_class.call - expect(result.size).to eq(1) - expect(result.first[:project]).to eq(bad_project) - end - end end describe "handle generation from project name" do @@ -127,35 +104,50 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do "Social media marketing" => "SMM", "Arcanos Mobile Web App" => "AMWA", "Flight Planning Training" => "FPT", - "A B C D E F G H I J K" => "ABCDEFGHIJ" + "A B C D E F G H I J K" => "ABCDEFGHIJ" # truncated to 10 chars }.each do |project_name, expected_handle| it "generates '#{expected_handle}' from '#{project_name}'" do project = instance_double(Project, identifier: "bad-id", name: project_name) - allow(Project).to receive(:all).and_return([project]) - result = described_class.call - expect(result.first[:suggested_handle]).to eq(expected_handle) + stub_query([project]) + expect(described_class.call.first[:suggested_handle]).to eq(expected_handle) end end end - describe "problematic identifier detection" do - valid_identifiers = %w[valid VALID123 abc arcanosweb] - problematic_identifiers = ["verylongidentifier", "12345678901", "arcanos-web", "fly_sky", "fly&sky"] - - valid_identifiers.each do |identifier| - it "does not flag '#{identifier}' as problematic" do - project = instance_double(Project, identifier:, name: "Test Project") - allow(Project).to receive(:all).and_return([project]) - expect(described_class.call).to be_empty - end + describe "unique_handle conflict resolution" do + it "uses the base handle when it is not yet taken" do + project = instance_double(Project, identifier: "sc-app", name: "Stream Communicator") + stub_query([project]) + expect(described_class.call.first[:suggested_handle]).to eq("SC") end - problematic_identifiers.each do |identifier| - it "flags '#{identifier}' as problematic" do - project = instance_double(Project, identifier:, name: "Test Project") - allow(Project).to receive(:all).and_return([project]) - expect(described_class.call).not_to be_empty - end + it "appends '2' when the base is already taken" do + p1 = instance_double(Project, identifier: "sc-app", name: "Stream Communicator") + p2 = instance_double(Project, identifier: "stream-ch", name: "Stream Channel") + stub_query([p1, p2]) + handles = described_class.call.pluck(:suggested_handle) + expect(handles).to contain_exactly("SC", "SC2") + end + + it "increments the suffix until unique" do + p1 = instance_double(Project, identifier: "sc-a", name: "Stream Communicator") + p2 = instance_double(Project, identifier: "sc-b", name: "Stream Channel") + p3 = instance_double(Project, identifier: "sc-c", name: "Something Cool") + stub_query([p1, p2, p3]) + handles = described_class.call.pluck(:suggested_handle) + expect(handles).to contain_exactly("SC", "SC2", "SC3") + end + + it "trims the base to fit within HANDLE_MAX_LENGTH when adding a suffix" do + # 10-char acronym: "ABCDEFGHIJ" + project1 = instance_double(Project, identifier: "a-b-c-d-e-f-g-h-i-j-k", + name: "A B C D E F G H I J") + project2 = instance_double(Project, identifier: "a-b-c-d-x", + name: "A B C D E F G H I J") # same acronym + stub_query([project1, project2]) + handles = described_class.call.pluck(:suggested_handle) + expect(handles.all? { |h| h.length <= 10 }).to be true + expect(handles.uniq.size).to eq(2) end end @@ -163,15 +155,15 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do context "when identifier is too long" do it "assigns :too_long" do project = instance_double(Project, identifier: "verylongidentifier", name: "Test") - allow(Project).to receive(:all).and_return([project]) + stub_query([project]) expect(described_class.call.first[:error_reason]).to eq(:too_long) end end - context "when identifier contains special characters" do + context "when identifier contains special characters but is not too long" do it "assigns :special_characters" do project = instance_double(Project, identifier: "my-project", name: "Test") - allow(Project).to receive(:all).and_return([project]) + stub_query([project]) expect(described_class.call.first[:error_reason]).to eq(:special_characters) end end @@ -179,7 +171,7 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do context "when identifier is both too long and has special chars" do it "assigns :too_long (length takes priority)" do project = instance_double(Project, identifier: "my-very-long-identifier", name: "Test") - allow(Project).to receive(:all).and_return([project]) + stub_query([project]) expect(described_class.call.first[:error_reason]).to eq(:too_long) end end From 2576d57dd9650ee01cef2c01a3b86a8db24fa11e Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 22:26:39 +0300 Subject: [PATCH 014/435] Improve handle suggestion algo: Unicode support + real-DB spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handle_from_name: replace /[a-zA-Z0-9]+/ with /[[:alpha:][:digit:]]+/ so accented letters (é, ñ, ü…) are kept inside their word rather than treated as separators. "Cécile Martin" now produces "CM" instead of "CCM". Transliterate each word's first character via I18n.transliterate (consistent with app/models/exports/exporter.rb) before uppercasing, so non-ASCII initials map to their ASCII equivalent (é→E, ñ→N). filter_map silently drops any initial that produces no usable character after transliteration. - Service spec: remove the receive_message_chain stub entirely. All fixtures are now real create(:project, ...) records so the SQL query is exercised against the test DB. shared_let used where the same record backs multiple it-blocks; inline create for single-assertion tests. Two new Unicode examples: "Cécile Martin" → "CM" and "étude de cas" → "EDC". --- .../project_handle_suggestion_generator.rb | 44 ++++--- ...roject_handle_suggestion_generator_spec.rb | 112 +++++++----------- 2 files changed, 70 insertions(+), 86 deletions(-) diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb index 790e1b7f6e5..defea8e9dcb 100644 --- a/app/services/work_packages/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/project_handle_suggestion_generator.rb @@ -44,20 +44,23 @@ module WorkPackages # # FIXME(project_handles): This class currently reads from the existing # Project#identifier column. Once the project_handles data model is available, - # replace #call with: + # replace #call with a query that finds projects with no current handle row: # - # ProjectHandle - # .select("project_handles.handle AS identifier, projects.id, projects.name") - # .joins(:project) - # .where(current: true) - # .where("length(handle) > ? OR handle ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") + # Project + # .select(:id, :name, :identifier) + # .where.not(id: ProjectHandle.where(current: true).select(:project_id)) # .to_a - # .then { |problematic| generate_suggestions(problematic) } + # .then { |projects_without_handle| generate_suggestions(projects_without_handle) } # - # The :current boolean on ProjectHandle marks the live handle; old handles are - # retained so that existing URLs continue to resolve. + # project_handles stores only valid (alphanumeric, ≤ HANDLE_MAX_LENGTH) handles. + # A project with no current handle is one whose Project#identifier has not yet been + # migrated; generate_suggestions still uses Project#identifier as current_identifier + # and error_reason still classifies why that identifier is problematic. + # The :current boolean marks the live handle; superseded handles are retained so + # that existing URLs continue to resolve via redirect. class ProjectHandleSuggestionGenerator HANDLE_MAX_LENGTH = 10 + FALLBACK_HANDLE = "PROJ" # @return [Array] one entry per project with a problematic identifier: # { project:, current_identifier:, suggested_handle:, error_reason: } @@ -67,8 +70,7 @@ module WorkPackages end def call - # FIXME(project_handles): Swap Project query for ProjectHandle query (see class doc above). - # Only select the three columns we need to avoid loading large text/JSON attributes. + # FIXME(project_handles): Replace with projects lacking a current handle — see class doc above. Project .select(:id, :name, :identifier) .where("length(identifier) > ? OR identifier ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") @@ -110,13 +112,25 @@ module WorkPackages # first letter of each word (acronym style): # "Flight Planning Algorithm" → "FPA" # "Fly & Sky" → "FS" - # Falls back to "P" when the name yields no alphanumeric words. + # "Cécile Martin" → "CM" (accented letters treated as one word) + # Falls back to "PROJ" when the name yields no usable initials. # Result is truncated to HANDLE_MAX_LENGTH characters. def handle_from_name(name) - words = name.to_s.scan(/[a-zA-Z0-9]+/) - return "P" if words.empty? + # Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside + # their word rather than treated as separators by the ASCII-only [a-zA-Z]. + words = name.to_s.scan(/[[:alpha:][:digit:]]+/) + return FALLBACK_HANDLE if words.empty? + + # Transliterate each word's first character to ASCII (é→e, ñ→n) then + # upcase. filter_map silently drops any initial that yields nothing useful + # after transliteration (e.g. a lone ideograph that maps to "?"). + acronym = words.filter_map do |word| + ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] + ch if ch&.match?(/\A[A-Z0-9]\z/) + end.join + + return FALLBACK_HANDLE if acronym.empty? - acronym = words.map { |w| w[0] }.join.upcase # rubocop:disable Rails/Pluck acronym.slice(0, HANDLE_MAX_LENGTH) end diff --git a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb index 0d14e5169be..5f48c71371d 100644 --- a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb @@ -30,29 +30,21 @@ require "rails_helper" +# Integration-style spec: real Project records are created in the test DB so that +# both the SQL query (WHERE length(identifier) > 10 OR identifier ~ '[^a-zA-Z0-9]') +# and the suggestion algorithm are exercised end-to-end. RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do - # Stub the SQL query chain used by #call. - # The WHERE clause in production filters out valid identifiers; tests control - # what the query "returns" by mocking the end of the chain. - def stub_query(projects) - allow(Project).to receive_message_chain(:select, :where, :to_a).and_return(projects) # rubocop:disable RSpec/MessageChain - end - describe ".call" do - context "when the query returns no projects (all identifiers are valid)" do - before { stub_query([]) } - + context "when all existing projects have valid identifiers" do it "returns an empty array" do expect(described_class.call).to be_empty end end - context "when the query returns a project with a too-long identifier" do - let(:project) { instance_double(Project, identifier: "verylongidentifier", name: "Very Long Identifier") } + context "when a project has a too-long identifier" do + shared_let(:project) { create(:project, identifier: "verylongidentifier", name: "Very Long Identifier") } - before { stub_query([project]) } - - it "returns a suggestion entry for the project" do + it "returns one suggestion entry for the project" do result = described_class.call expect(result.size).to eq(1) expect(result.first[:project]).to eq(project) @@ -63,10 +55,8 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do end end - context "when the query returns a project with a special-character identifier" do - let(:project) { instance_double(Project, identifier: "fly-sky", name: "Fly Sky") } - - before { stub_query([project]) } + context "when a project has a special-character identifier" do + shared_let(:project) { create(:project, identifier: "fly-sky", name: "Fly Sky") } it "returns a suggestion entry with error_reason :special_characters" do result = described_class.call @@ -75,22 +65,17 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do end end - context "when multiple projects would generate conflicting handles" do - let(:project_sc1) { instance_double(Project, identifier: "sc-app", name: "Stream Communicator") } - let(:project_sc2) { instance_double(Project, identifier: "stream-channel", name: "Stream Channel") } - - before { stub_query([project_sc1, project_sc2]) } + context "when multiple projects generate conflicting handles" do + shared_let(:project_sc1) { create(:project, identifier: "sc-app", name: "Stream Communicator") } + shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") } it "generates unique handles for each project" do - result = described_class.call - handles = result.pluck(:suggested_handle) + handles = described_class.call.pluck(:suggested_handle) expect(handles.uniq.size).to eq(handles.size) end it "appends a numeric suffix to resolve conflicts" do - result = described_class.call - handles = result.pluck(:suggested_handle) - # "Stream Communicator" → "SC", "Stream Channel" → "SC2" + handles = described_class.call.pluck(:suggested_handle) expect(handles).to include("SC") expect(handles.any? { |h| h.match?(/\ASC\d+\z/) }).to be true end @@ -98,53 +83,47 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do end describe "handle generation from project name" do + # Each example creates one project whose identifier has a hyphen (special char) + # so the SQL query picks it up, then asserts the suggested handle from the name. { "Flight Planning Algorithm" => "FPA", "Fly & Sky" => "FS", "Social media marketing" => "SMM", - "Arcanos Mobile Web App" => "AMWA", + "Arcanos (mobile-web-app)" => "AMWA", "Flight Planning Training" => "FPT", - "A B C D E F G H I J K" => "ABCDEFGHIJ" # truncated to 10 chars + "A B C D E F G H I J K" => "ABCDEFGHIJ", # truncated to 10 chars + "Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"] + "étude de cas" => "EDC" # Unicode: é→E via transliteration }.each do |project_name, expected_handle| it "generates '#{expected_handle}' from '#{project_name}'" do - project = instance_double(Project, identifier: "bad-id", name: project_name) - stub_query([project]) + create(:project, identifier: "bad-id", name: project_name) expect(described_class.call.first[:suggested_handle]).to eq(expected_handle) end end end describe "unique_handle conflict resolution" do - it "uses the base handle when it is not yet taken" do - project = instance_double(Project, identifier: "sc-app", name: "Stream Communicator") - stub_query([project]) + it "uses the base handle when not yet taken" do + create(:project, identifier: "sc-app", name: "Stream Communicator") expect(described_class.call.first[:suggested_handle]).to eq("SC") end it "appends '2' when the base is already taken" do - p1 = instance_double(Project, identifier: "sc-app", name: "Stream Communicator") - p2 = instance_double(Project, identifier: "stream-ch", name: "Stream Channel") - stub_query([p1, p2]) - handles = described_class.call.pluck(:suggested_handle) - expect(handles).to contain_exactly("SC", "SC2") + create(:project, identifier: "sc-app", name: "Stream Communicator") + create(:project, identifier: "stream-ch", name: "Stream Channel") + expect(described_class.call.pluck(:suggested_handle)).to contain_exactly("SC", "SC2") end it "increments the suffix until unique" do - p1 = instance_double(Project, identifier: "sc-a", name: "Stream Communicator") - p2 = instance_double(Project, identifier: "sc-b", name: "Stream Channel") - p3 = instance_double(Project, identifier: "sc-c", name: "Something Cool") - stub_query([p1, p2, p3]) - handles = described_class.call.pluck(:suggested_handle) - expect(handles).to contain_exactly("SC", "SC2", "SC3") + create(:project, identifier: "sc-a", name: "Stream Communicator") + create(:project, identifier: "sc-b", name: "Stream Channel") + create(:project, identifier: "sc-c", name: "Something Cool") + expect(described_class.call.pluck(:suggested_handle)).to contain_exactly("SC", "SC2", "SC3") end it "trims the base to fit within HANDLE_MAX_LENGTH when adding a suffix" do - # 10-char acronym: "ABCDEFGHIJ" - project1 = instance_double(Project, identifier: "a-b-c-d-e-f-g-h-i-j-k", - name: "A B C D E F G H I J") - project2 = instance_double(Project, identifier: "a-b-c-d-x", - name: "A B C D E F G H I J") # same acronym - stub_query([project1, project2]) + create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") + create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") handles = described_class.call.pluck(:suggested_handle) expect(handles.all? { |h| h.length <= 10 }).to be true expect(handles.uniq.size).to eq(2) @@ -152,28 +131,19 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do end describe "error reason assignment" do - context "when identifier is too long" do - it "assigns :too_long" do - project = instance_double(Project, identifier: "verylongidentifier", name: "Test") - stub_query([project]) - expect(described_class.call.first[:error_reason]).to eq(:too_long) - end + it "assigns :too_long when identifier length exceeds HANDLE_MAX_LENGTH" do + create(:project, identifier: "verylongidentifier", name: "Test") + expect(described_class.call.first[:error_reason]).to eq(:too_long) end - context "when identifier contains special characters but is not too long" do - it "assigns :special_characters" do - project = instance_double(Project, identifier: "my-project", name: "Test") - stub_query([project]) - expect(described_class.call.first[:error_reason]).to eq(:special_characters) - end + it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do + create(:project, identifier: "my-project", name: "Test") + expect(described_class.call.first[:error_reason]).to eq(:special_characters) end - context "when identifier is both too long and has special chars" do - it "assigns :too_long (length takes priority)" do - project = instance_double(Project, identifier: "my-very-long-identifier", name: "Test") - stub_query([project]) - expect(described_class.call.first[:error_reason]).to eq(:too_long) - end + it "assigns :too_long (priority) when identifier is both too long and has special chars" do + create(:project, identifier: "my-very-long-identifier", name: "Test") + expect(described_class.call.first[:error_reason]).to eq(:too_long) end end end From d38006c0d325be8e07914f1ed39f69298dc2a1d9 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 22:37:10 +0300 Subject: [PATCH 015/435] Show realistic example WP IDs in autofix preview table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded '-1' suffix with a deterministic sample number derived from the handle string (range 1–500, zero-padded for single digits). Each handle consistently produces the same example ID on every render, but IDs look naturally varied across different projects. --- .../identifier_autofix_preview_component.html.erb | 2 +- .../settings/identifier_autofix_preview_component.rb | 9 +++++++++ .../identifier_autofix_preview_component_spec.rb | 7 ++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb index a69586c3475..a734540c822 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb @@ -80,7 +80,7 @@ render(Primer::Beta::Text.new) { entry[:suggested_handle] } end row.with_column(flex: 1) do - render(Primer::Beta::Text.new) { "#{entry[:suggested_handle]}-1" } + render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_handle]) } end end end diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb index a8267629b97..43aa64d74c8 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb @@ -56,6 +56,15 @@ module WorkPackages I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_special_characters") end end + + # Produces a realistic-looking example work package ID for the preview table. + # The sequence number is derived deterministically from the handle so it looks + # varied across projects but is stable across renders. Range: 1–500. + # Single-digit numbers are zero-padded ("FP-07"), two/three digits are not ("FP-42"). + def sample_wp_id(handle) + n = (handle.bytes.sum % 500) + 1 + "#{handle}-#{format('%02d', n)}" + end end end end diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb index 24eaea8bf2d..09196c6a56f 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb @@ -74,10 +74,11 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent, expect(page).to have_text("VLNP") end - it "shows an example work package ID with -1 suffix" do + it "shows a realistic example work package ID" do render_inline(component) - expect(page).to have_text("FP-1") - expect(page).to have_text("VLNP-1") + # Numbers are deterministic from the handle's byte sum; format is handle + zero-padded number. + expect(page).to have_text("FP-151") # "FP".bytes.sum % 500 + 1 = 151 + expect(page).to have_text("VLNP-321") # "VLNP".bytes.sum % 500 + 1 = 321 end it "shows the special characters error caption" do From 3baf3cc907f035b3643ed4dc25343df027935c10 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 22:53:54 +0300 Subject: [PATCH 016/435] Mark future :handle_reserved and :identifier_taken error cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds FIXME(project_handles) markers in the suggestion generator, preview component, and i18n file to document the two error cases that require the project_handles model before they can be implemented: - :handle_reserved — identifier already stored in project_handles for another project (current or historical); all handle values route permanently to their owning project and cannot be reassigned - :identifier_taken — identifier is a valid format and will be auto-adopted as another project's handle during migration, making it unavailable for this project FIXME comments cover: - Class-level doc and @return YARD tag in the generator - #call: explains the future query naturally catches both new cases - #generate_suggestions: shows DB pre-seed needed for used_handles - #error_reason: stubs the two new branches with priority order - IdentifierAutofixPreviewComponent#error_label: stubs two new whens - en.yml: placeholder copy for error_handle_reserved / error_identifier_taken --- .../identifier_autofix_preview_component.rb | 3 ++ .../project_handle_suggestion_generator.rb | 44 ++++++++++++++++--- config/locales/en.yml | 3 ++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb index 43aa64d74c8..4a484da4c8e 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb @@ -54,6 +54,9 @@ module WorkPackages I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_too_long") when :special_characters I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_special_characters") + # FIXME(project_handles): Add when :handle_reserved and :identifier_taken + # with their respective i18n keys (error_handle_reserved / error_identifier_taken) + # once the model and final copy are confirmed. end end diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb index defea8e9dcb..0649f283829 100644 --- a/app/services/work_packages/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/project_handle_suggestion_generator.rb @@ -30,12 +30,16 @@ module WorkPackages # Scans projects for identifiers that do not meet alphanumeric handle - # requirements (too long or containing non-alphanumeric characters) and - # generates a short uppercase acronym suggestion for each one. + # requirements and generates a short uppercase acronym suggestion for each one. # - # A "problematic" identifier is one that: - # - contains any character outside [a-zA-Z0-9], or - # - is longer than HANDLE_MAX_LENGTH (10) characters + # A "problematic" identifier is one that (error_reason): + # - is longer than HANDLE_MAX_LENGTH (10) characters → :too_long + # - contains any character outside [a-zA-Z0-9] → :special_characters + # FIXME(project_handles): Two further cases once model exists: + # - the identifier string is already in project_handles for + # another project (current or historical) → :handle_reserved + # - the identifier is valid format but will be auto-adopted + # as another project's handle during migration → :identifier_taken # # The suggestion is derived from the project name: taking the first letter of # each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two @@ -64,13 +68,18 @@ module WorkPackages # @return [Array] one entry per project with a problematic identifier: # { project:, current_identifier:, suggested_handle:, error_reason: } - # error_reason is :too_long or :special_characters + # error_reason is :too_long, :special_characters, + # :handle_reserved, or :identifier_taken (last two: FIXME project_handles) def self.call new.call end def call # FIXME(project_handles): Replace with projects lacking a current handle — see class doc above. + # Note: the future query (Project.where.not(id: ProjectHandle.where(current:true)…)) + # also naturally surfaces :handle_reserved and :identifier_taken projects because + # they too will have no valid current handle row. No extra WHERE filter is needed; + # the key change is pre-seeding used_handles in generate_suggestions (see below). Project .select(:id, :name, :identifier) .where("length(identifier) > ? OR identifier ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") @@ -86,12 +95,35 @@ module WorkPackages else :special_characters end + # FIXME(project_handles): Add two further branches (checked after the above): + # + # :handle_reserved — identifier already in project_handles for another + # project (any row, current or historical) + # :identifier_taken — identifier is valid format and will be auto-adopted + # as another project's handle during migration + # + # Priority order: :too_long > :special_characters > + # :handle_reserved > :identifier_taken end # Builds the suggestion list for a set of problematic projects. # Handles are generated in iteration order; duplicates are resolved in-place # so the final list is guaranteed to contain no two identical handles. def generate_suggestions(projects) + # FIXME(project_handles): Pre-seed used_handles from the DB before iterating + # so suggestions never collide with handles already in use across all projects: + # + # used_handles.merge(ProjectHandle.pluck(:handle)) + # # ^ every handle ever assigned to any project (current + historical) + # # → prevents :handle_reserved conflicts + # + # used_handles.merge( + # Project.where("length(identifier) <= ? AND identifier ~ ?", + # HANDLE_MAX_LENGTH, "^[A-Za-z0-9]+$") + # .pluck(:identifier) + # ) + # # ^ valid-format identifiers that will be auto-adopted as handles + # # → prevents :identifier_taken conflicts used_handles = Set.new projects.map do |project| diff --git a/config/locales/en.yml b/config/locales/en.yml index 41770122c17..de4415f50d8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -224,6 +224,9 @@ en: autofix_preview: error_too_long: Has to be fewer than 10 characters error_special_characters: Special characters not allowed + # FIXME(project_handles): Confirm final copy for these two labels + error_handle_reserved: Handle reserved by another project + error_identifier_taken: Identifier already used by another project remaining_projects: one: "... 1 more project" other: "... %{count} more projects" From d7d9c84f3948922d9d76c48ea0a4febeb33eb65a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 23:04:10 +0300 Subject: [PATCH 017/435] Rename PreviewComponent to SectionComponent; merge banner into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdentifierAutofixPreviewComponent → IdentifierAutofixSectionComponent. The banner (existing_identifiers_notice) and the preview table always appear together, so they are now co-located in a single component. The parent template renders one component instead of a banner + separate component. Changes: - git mv preview → section for all three files (rb, erb, spec) - Add @total_count to the initializer for the banner's project_count - Template: banner rendered above the border-box table - Parent template: remove inline banner render, call SectionComponent - Spec: update described_class; add banner count assertion --- ...=> identifier_autofix_section_component.html.erb} | 9 +++++++++ ...nt.rb => identifier_autofix_section_component.rb} | 5 +++-- .../identifier_settings_form_component.html.erb | 10 +--------- .../settings/identifier_settings_form_component.rb | 8 +++----- ... => identifier_autofix_section_component_spec.rb} | 12 +++++++++++- 5 files changed, 27 insertions(+), 17 deletions(-) rename app/components/work_packages/admin/settings/{identifier_autofix_preview_component.html.erb => identifier_autofix_section_component.html.erb} (94%) rename app/components/work_packages/admin/settings/{identifier_autofix_preview_component.rb => identifier_autofix_section_component.rb} (94%) rename spec/components/work_packages/admin/settings/{identifier_autofix_preview_component_spec.rb => identifier_autofix_section_component_spec.rb} (93%) diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb similarity index 94% rename from app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb rename to app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb index a734540c822..8ab32af8ec6 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb @@ -29,6 +29,15 @@ ++# %> +<%= + render(Primer::Alpha::Banner.new(scheme: :warning, my: 3)) do + I18n.t( + "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", + project_count: total_count + ) + end +%> + <%= render(border_box_container(mb: 3)) do |component| component.with_header(font_weight: :bold) do diff --git a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb similarity index 94% rename from app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb rename to app/components/work_packages/admin/settings/identifier_autofix_section_component.rb index 4a484da4c8e..57b206282c6 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_preview_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb @@ -31,7 +31,7 @@ module WorkPackages module Admin module Settings - class IdentifierAutofixPreviewComponent < ApplicationComponent + class IdentifierAutofixSectionComponent < ApplicationComponent include OpPrimer::ComponentHelpers DISPLAY_COUNT = 5 @@ -40,13 +40,14 @@ module WorkPackages # Each hash: { project:, current_identifier:, suggested_handle:, error_reason: } def initialize(projects_data:) super() + @total_count = projects_data.size @displayed = projects_data.first(DISPLAY_COUNT) @remaining_count = [projects_data.size - DISPLAY_COUNT, 0].max end private - attr_reader :displayed, :remaining_count + attr_reader :total_count, :displayed, :remaining_count def error_label(error_reason) case error_reason diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index 633d5bd2609..2b6fe8f911b 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -75,17 +75,9 @@ hidden: !show_autofix_section?, data: { admin__work_packages_identifier_target: "autofixSection" } ) do %> - <%= - render(Primer::Alpha::Banner.new(scheme: :warning, mb: 3)) do - t( - "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", - project_count: projects_data.size - ) - end - %> <%= render( - WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent.new(projects_data:) + WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent.new(projects_data:) ) %> <% end %> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index 16dbbab1d27..ca2cf4c271d 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -35,17 +35,15 @@ module WorkPackages class IdentifierSettingsFormComponent < ApplicationComponent include OpPrimer::FormHelpers + attr_reader :projects_data + def initialize super @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call end def has_problematic_projects? - @projects_data.any? - end - - def projects_data - @projects_data + projects_data.any? end private diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb similarity index 93% rename from spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb rename to spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb index 09196c6a56f..da36555059c 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_preview_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent, type: :component do +RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent, type: :component do include OpenProject::StaticRouting::UrlHelpers def build_entry(name:, identifier:, handle:, error_reason:) @@ -90,6 +90,16 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixPreviewComponent, render_inline(component) expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_too_long")) end + + it "renders the warning banner with the total project count" do + render_inline(component) + expect(page).to have_text( + I18n.t( + "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", + project_count: 2 + ) + ) + end end context "with exactly 5 projects" do From f02ef008db1d36034cb807154336deaed29ccf94 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 23:21:38 +0300 Subject: [PATCH 018/435] Fix WorkPackageIdentifier encapsulation and lazy-load guard Three issues corrected: 1. Bug fix: ALLOWED_VALUES is an Array; ALLOWED_VALUES[:alphanumeric] raises TypeError at runtime (symbols are not valid Array indices). Extract named string constants NUMERIC and ALPHANUMERIC so comparisons are explicit. 2. Lazy-load guard: ProjectHandleSuggestionGenerator ran a DB query on every component render, even in numeric mode where the result is never used. Now the query only runs when alphanumeric? is true; numeric mode gets []. 3. show_autofix_section? simplified: the alphanumeric? guard moved into the initializer, so the private method is now just projects_data.any?. Also: wrap definition.rb's `allowed:` in a lambda to defer constant resolution past Rails autoload (fixes a load-order error in specs), and add a spec for Setting::WorkPackageIdentifier covering the bug scenario. --- .../identifier_settings_form_component.rb | 8 +++- app/models/setting/work_package_identifier.rb | 40 +++++++++++++++++ config/constants/settings/definition.rb | 4 +- .../setting/work_package_identifier_spec.rb | 43 +++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 app/models/setting/work_package_identifier.rb create mode 100644 spec/models/setting/work_package_identifier_spec.rb diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index ca2cf4c271d..7cf62089a46 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -39,7 +39,11 @@ module WorkPackages def initialize super - @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call + @projects_data = if Setting::WorkPackageIdentifier.alphanumeric? + WorkPackages::ProjectHandleSuggestionGenerator.call + else + [] + end end def has_problematic_projects? @@ -49,7 +53,7 @@ module WorkPackages private def show_autofix_section? - Setting[:work_packages_identifier] == "alphanumeric" && has_problematic_projects? + projects_data.any? end end end diff --git a/app/models/setting/work_package_identifier.rb b/app/models/setting/work_package_identifier.rb new file mode 100644 index 00000000000..fb0004bada8 --- /dev/null +++ b/app/models/setting/work_package_identifier.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Setting + module WorkPackageIdentifier + NUMERIC = "numeric" + ALPHANUMERIC = "alphanumeric" + ALLOWED_VALUES = [NUMERIC, ALPHANUMERIC].freeze + + def self.alphanumeric? = Setting[:work_packages_identifier] == ALPHANUMERIC + def self.numeric? = Setting[:work_packages_identifier] == NUMERIC + end +end diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 664989a9815..7f4f7f900ff 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -1281,7 +1281,7 @@ module Settings "while 'alphanumeric' uses the project identifier and the work package ID separated by a dash " \ "(e.g. 'PROJA-123').", format: :string, - allowed: %w[numeric alphanumeric], + allowed: -> { Setting::WorkPackageIdentifier::ALLOWED_VALUES }, default: "numeric" }, work_package_list_default_highlighted_attributes: { @@ -1596,7 +1596,7 @@ module Settings env_var_hash_part .scan(/(?:[a-zA-Z0-9]|__)+/) .map do |seg| - unescape_underscores(seg.downcase) + unescape_underscores(seg.downcase) end end diff --git a/spec/models/setting/work_package_identifier_spec.rb b/spec/models/setting/work_package_identifier_spec.rb new file mode 100644 index 00000000000..b61e5d99be9 --- /dev/null +++ b/spec/models/setting/work_package_identifier_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Setting::WorkPackageIdentifier do + context "when the setting is 'alphanumeric'", with_settings: { work_packages_identifier: "alphanumeric" } do + it { expect(described_class.alphanumeric?).to be true } + it { expect(described_class.numeric?).to be false } + end + + context "when the setting is 'numeric'", with_settings: { work_packages_identifier: "numeric" } do + it { expect(described_class.alphanumeric?).to be false } + it { expect(described_class.numeric?).to be true } + end +end From 20b7a280f68719444c7e0453b9806d6966a33049 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 25 Feb 2026 23:24:50 +0300 Subject: [PATCH 019/435] Revert lazy-load guard on ProjectHandleSuggestionGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autofix section must be pre-rendered with data on every page load, even when the current setting is 'numeric'. The Stimulus controller reveals the section as soon as the user selects 'alphanumeric' — before the form is saved — so the table and banner data must already be in the DOM. show_autofix_section? is restored to guard on alphanumeric? so the section is hidden initially in numeric mode but still populated. --- .../admin/settings/identifier_settings_form_component.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index 7cf62089a46..f996144c144 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -39,11 +39,7 @@ module WorkPackages def initialize super - @projects_data = if Setting::WorkPackageIdentifier.alphanumeric? - WorkPackages::ProjectHandleSuggestionGenerator.call - else - [] - end + @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call end def has_problematic_projects? @@ -53,7 +49,7 @@ module WorkPackages private def show_autofix_section? - projects_data.any? + Setting::WorkPackageIdentifier.alphanumeric? && has_problematic_projects? end end end From def97b72fd56ff23a10908321d19e44136c216f6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 26 Feb 2026 12:09:38 +0300 Subject: [PATCH 020/435] Test non-Latin script fallback in handle generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents and pins the behaviour for scripts without transliteration entries (Japanese, Chinese, Arabic, …): - Fully non-Latin name (e.g. "日本語プロジェクト"): every initial maps to "?" via I18n.transliterate, filter_map drops them all, empty acronym falls back to FALLBACK_HANDLE ("PROJ"). - Mixed name (e.g. "Plan 日本"): Latin initials survive, non-Latin ones are silently dropped, result is the Latin-only acronym ("P"). --- .../project_handle_suggestion_generator_spec.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb index 5f48c71371d..2360fc4dcdf 100644 --- a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb @@ -93,7 +93,11 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do "Flight Planning Training" => "FPT", "A B C D E F G H I J K" => "ABCDEFGHIJ", # truncated to 10 chars "Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"] - "étude de cas" => "EDC" # Unicode: é→E via transliteration + "étude de cas" => "EDC", # Unicode: é→E via transliteration + # Non-Latin scripts have no transliteration entries (I18n.transliterate → "?"). + # All initials are dropped and the name falls back to FALLBACK_HANDLE. + "日本語プロジェクト" => "PROJ", # Japanese: every initial → "?" → fallback + "Plan 日本" => "P" # Mixed: Latin "P" survives; "日" is dropped }.each do |project_name, expected_handle| it "generates '#{expected_handle}' from '#{project_name}'" do create(:project, identifier: "bad-id", name: project_name) From ad2d56e1ace01bf6ee4044a7792584f0e67e26eb Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 26 Feb 2026 12:19:10 +0300 Subject: [PATCH 021/435] Add SUFFIX_LIMIT guard to unique_handle to prevent infinite loop Addresses code review: the loop in unique_handle had no upper bound. Adds SUFFIX_LIMIT = 10_000 and raises if the counter exceeds it. 10 000 projects sharing one acronym is not reachable in practice; hitting the limit would indicate a bug in used_handles pre-seeding. The other three review points were false positives: - Module name WorkPackages:: is correct for this feature's domain - SQL ? binding sanitises the regex pattern (no injection surface) - upcase[0] nil case is already handled by ch&.match? on the next line --- .../project_handle_suggestion_generator.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb index 0649f283829..791c70d634b 100644 --- a/app/services/work_packages/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/project_handle_suggestion_generator.rb @@ -65,6 +65,10 @@ module WorkPackages class ProjectHandleSuggestionGenerator HANDLE_MAX_LENGTH = 10 FALLBACK_HANDLE = "PROJ" + # Upper bound for suffix counter — prevents an infinite loop if used_handles + # is somehow saturated. 10 000 projects sharing an acronym is unreachable in + # practice; raising here indicates a serious bug in the caller's pre-seeding. + SUFFIX_LIMIT = 10_000 # @return [Array] one entry per project with a problematic identifier: # { project:, current_identifier:, suggested_handle:, error_reason: } @@ -84,7 +88,7 @@ module WorkPackages .select(:id, :name, :identifier) .where("length(identifier) > ? OR identifier ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") .to_a - .then { |problematic| generate_suggestions(problematic) } + .then { generate_suggestions(it) } end private @@ -178,6 +182,7 @@ module WorkPackages # @param base [String] the acronym to start from (already ≤ HANDLE_MAX_LENGTH) # @param used_handles [Set] handles already assigned in this batch # @return [String] a unique handle ≤ HANDLE_MAX_LENGTH + # @raise [RuntimeError] if no unique candidate is found within SUFFIX_LIMIT def unique_handle(base, used_handles) # Fast path: acronym is unique, no suffix needed. return base unless used_handles.include?(base) @@ -186,6 +191,9 @@ module WorkPackages # never exceeds HANDLE_MAX_LENGTH characters. counter = 2 loop do + raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ + if counter > SUFFIX_LIMIT + suffix = counter.to_s candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}" break candidate unless used_handles.include?(candidate) From 5f83f293682549efaca62204d99ac4883c824ab2 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 26 Feb 2026 17:54:10 +0300 Subject: [PATCH 022/435] Add confirmation dialog, in-progress UI, and extract radio form component --- ...ange_identifiers_dialog_component.html.erb | 64 +++++++++ .../change_identifiers_dialog_component.rb | 39 +++++ ...ntifier_autofix_section_component.html.erb | 2 +- ...fier_change_in_progress_component.html.erb | 39 +++++ ...identifier_change_in_progress_component.rb | 39 +++++ .../identifier_radio_form_component.html.erb | 46 ++++++ .../identifier_radio_form_component.rb | 49 +++++++ ...dentifier_settings_form_component.html.erb | 63 +++++---- config/locales/en.yml | 11 ++ .../work-packages-identifier.controller.ts | 16 ++- .../settings/work_packages_identifier_spec.rb | 133 ++++++++++++++++++ 11 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 app/components/work_packages/admin/settings/change_identifiers_dialog_component.html.erb create mode 100644 app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb create mode 100644 app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb create mode 100644 app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb create mode 100644 app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb create mode 100644 app/components/work_packages/admin/settings/identifier_radio_form_component.rb create mode 100644 spec/features/admin/settings/work_packages_identifier_spec.rb diff --git a/app/components/work_packages/admin/settings/change_identifiers_dialog_component.html.erb b/app/components/work_packages/admin/settings/change_identifiers_dialog_component.html.erb new file mode 100644 index 00000000000..480cf64bb7c --- /dev/null +++ b/app/components/work_packages/admin/settings/change_identifiers_dialog_component.html.erb @@ -0,0 +1,64 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= + render( + Primer::OpenProject::DangerDialog.new( + id: "change-identifiers-dialog", + title: I18n.t("admin.settings.work_packages_identifier.dialog.title"), + confirm_button_text: I18n.t("admin.settings.work_packages_identifier.dialog.confirm_button"), + cancel_button_text: I18n.t("button_close"), + size: :large, + form_arguments: { + action: admin_settings_work_packages_identifier_path, + method: :patch + } + ) + ) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) do + I18n.t("admin.settings.work_packages_identifier.dialog.heading") + end + + message.with_description_content( + I18n.t("admin.settings.work_packages_identifier.dialog.description") + ) + end + + dialog.with_confirmation_check_box_content( + I18n.t("admin.settings.work_packages_identifier.dialog.checkbox_label") + ) + + dialog.with_additional_details(display: :none) do + hidden_field_tag("settings[work_packages_identifier]", Setting::WorkPackageIdentifier::ALPHANUMERIC) + end + end +%> diff --git a/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb b/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb new file mode 100644 index 00000000000..1b5f265bdcf --- /dev/null +++ b/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Admin + module Settings + class ChangeIdentifiersDialogComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + end + end + end +end diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb index 8ab32af8ec6..24d7c6cbd96 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb @@ -95,7 +95,7 @@ end end - if remaining_count > 0 + if remaining_count.positive? component.with_row do render(Primer::Beta::Text.new(color: :muted)) do I18n.t( diff --git a/app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb b/app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb new file mode 100644 index 00000000000..1c2d3777855 --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb @@ -0,0 +1,39 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= render(Primer::Alpha::Banner.new(scheme: :info, my: 3)) do %> + <%= render(Primer::Beta::Spinner.new(size: :small, mr: 2)) %> + <%= I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message") %> +<% end %> + +
+ <%= render(WorkPackages::Admin::Settings::IdentifierRadioFormComponent.new) %> +
diff --git a/app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb b/app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb new file mode 100644 index 00000000000..441a1b0128a --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Admin + module Settings + class IdentifierChangeInProgressComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + end + end + end +end diff --git a/app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb new file mode 100644 index 00000000000..e03389c1e1c --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb @@ -0,0 +1,46 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= + settings_primer_form_with( + scope: :settings, action: :update, method: :patch, + html: form_html + ) do |f| + render_inline_settings_form(f) do |form| + form.radio_button_group( + name: :work_packages_identifier, + label: I18n.t("settings.work_packages.work_package_identifier"), + required: true, + **radio_button_options + ) + end + end +%> diff --git a/app/components/work_packages/admin/settings/identifier_radio_form_component.rb b/app/components/work_packages/admin/settings/identifier_radio_form_component.rb new file mode 100644 index 00000000000..5ed20e344a3 --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_radio_form_component.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Admin + module Settings + class IdentifierRadioFormComponent < ApplicationComponent + include OpPrimer::FormHelpers + + def initialize(form_html: {}, radio_button_options: {}) + super() + @form_html = form_html + @radio_button_options = radio_button_options + end + + private + + attr_reader :form_html, :radio_button_options + end + end + end +end diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index 2b6fe8f911b..c05c75311c5 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -35,39 +35,35 @@ [settings_primer_form_with — 680px wrapper, radio buttons only] ... [full-width autofix section — banner + table, Stimulus target] - [op-admin-settings-form-wrapper — 680px, submit button linked via HTML5 form attr] + [op-admin-settings-form-wrapper — 680px, submit buttons] + "Save" — type: :submit, visible when autofix section is hidden + "Autofix & save" — type: :button, visible when autofix section is shown; + opens the confirmation dialog via Stimulus before POSTing + [change-identifiers-dialog — DangerDialog, opened by Stimulus on confirm] - The submit button sits outside the
element but is linked back to it via - the HTML5 `form="wp-identifier-settings-form"` attribute, so clicking it still - submits the form. This allows the banner + table to be full-width while keeping - the radio buttons and submit button constrained to the standard 680px admin width. + Two separate buttons are rendered (each fully Primer-accessible) rather than + overwriting textContent on a single button, which would purge Primer's inner + accessibility spans/aria nodes. %> <%= tag.div( data: { controller: "admin--work-packages-identifier", - admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects?, - admin__work_packages_identifier_autofix_label_value: t("admin.settings.work_packages_identifier.button_autofix"), - admin__work_packages_identifier_save_label_value: t("button_save") + admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects? } ) do %> <%# Constrained-width form: radio buttons only (no submit inside the form element) %> <%= - settings_primer_form_with( - scope: :settings, action: :update, method: :patch, - html: { id: "wp-identifier-settings-form" } - ) do |f| - render_inline_settings_form(f) do |form| - form.radio_button_group( - name: :work_packages_identifier, - label: I18n.t("settings.work_packages.work_package_identifier"), - required: true, + render( + WorkPackages::Admin::Settings::IdentifierRadioFormComponent.new( + form_html: { id: "wp-identifier-settings-form" }, + radio_button_options: { button_options: { data: { action: "change->admin--work-packages-identifier#handleChange" } } - ) - end - end + } + ) + ) %> <%# Full-width autofix section — outside the 680px settings_primer_form_with wrapper %> @@ -82,7 +78,9 @@ %> <% end %> - <%# Submit button: constrained to 680px, linked to the form via HTML5 `form` attribute %> + <%# Submit buttons: constrained to 680px %> + <%# "Save" — direct submit, shown when there is no autofix section %> + <%# "Autofix and save" — opens dialog, shown when autofix section is visible %>
<%= render( @@ -90,11 +88,26 @@ scheme: :primary, type: :submit, form: "wp-identifier-settings-form", - data: { admin__work_packages_identifier_target: "submitButton" } + hidden: show_autofix_section?, + data: { admin__work_packages_identifier_target: "saveButton" } ) - ) do - show_autofix_section? ? t("admin.settings.work_packages_identifier.button_autofix") : t("button_save") - end + ) { t("button_save") } + %> + <%= + render( + Primer::Beta::Button.new( + scheme: :primary, + type: :button, + hidden: !show_autofix_section?, + data: { + admin__work_packages_identifier_target: "autofixButton", + action: "click->admin--work-packages-identifier#openConfirmDialog" + } + ) + ) { t("admin.settings.work_packages_identifier.button_autofix") } %>
+ + <%# Confirmation dialog — opened by Stimulus when "Autofix and save" is clicked %> + <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index de4415f50d8..facff3dbf4b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -231,6 +231,17 @@ en: one: "... 1 more project" other: "... %{count} more projects" button_autofix: Autofix and save + dialog: + title: Change work package identifiers + heading: Enable project-based work package IDs? + description: > + This will change IDs for all work packages in all projects in this instance. + Previous identifiers and URLs will continue to redirect properly. + This change will take some time to complete. + confirm_button: Change identifiers + checkbox_label: I understand that this will permanently change all work package IDs + in_progress: + banner_message: Project identifiers are currently being updated... workflows: tabs: diff --git a/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts index 15168b3f5e1..d65d2fcd44e 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts @@ -33,18 +33,15 @@ import { Controller } from '@hotwired/stimulus'; export default class WorkPackagesIdentifierController extends Controller { static values = { hasProblematicProjects: Boolean, - autofixLabel: String, - saveLabel: String, }; - static targets = ['autofixSection', 'submitButton']; + static targets = ['autofixSection', 'saveButton', 'autofixButton']; declare readonly hasProblematicProjectsValue:boolean; - declare readonly autofixLabelValue:string; - declare readonly saveLabelValue:string; declare readonly autofixSectionTarget:HTMLElement; - declare readonly submitButtonTarget:HTMLButtonElement; + declare readonly saveButtonTarget:HTMLButtonElement; + declare readonly autofixButtonTarget:HTMLButtonElement; connect() { this.updateVisibility(); @@ -54,11 +51,16 @@ export default class WorkPackagesIdentifierController extends Controller { this.updateVisibility(); } + openConfirmDialog() { + (document.getElementById('change-identifiers-dialog') as HTMLDialogElement)?.showModal(); + } + private updateVisibility() { const showAutofix = this.isAlphanumericSelected() && this.hasProblematicProjectsValue; this.autofixSectionTarget.hidden = !showAutofix; - this.submitButtonTarget.textContent = showAutofix ? this.autofixLabelValue : this.saveLabelValue; + this.saveButtonTarget.hidden = showAutofix; + this.autofixButtonTarget.hidden = !showAutofix; } private isAlphanumericSelected():boolean { diff --git a/spec/features/admin/settings/work_packages_identifier_spec.rb b/spec/features/admin/settings/work_packages_identifier_spec.rb new file mode 100644 index 00000000000..577acf9edfa --- /dev/null +++ b/spec/features/admin/settings/work_packages_identifier_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "Work packages identifier admin settings", :js do + shared_let(:admin) { create(:admin) } + + before do + with_flags(semantic_work_package_ids: true) + login_as(admin) + end + + let(:settings_path) { "/admin/settings/work_packages_identifier" } + + def visit_settings + visit settings_path + # Wait for the page heading to confirm the page has loaded + expect(page).to have_css("h2, h1", text: I18n.t("settings.work_packages.work_package_identifier"), + wait: 10) + end + + context "when no projects have problematic identifiers" do + it "saves the setting without showing a dialog" do + visit_settings + + click_button I18n.t("button_save") + + expect(page).to have_current_path(settings_path) + expect(page).to have_no_css("[role=alertdialog]") + end + end + + context "when a project has a problematic identifier" do + shared_let(:project) { create(:project, identifier: "bad-id", name: "Bad Project") } + + context "when saving with the current numeric setting" do + it "saves without showing the confirmation dialog" do + visit_settings + + # The autofix section is hidden when numeric is selected + expect(page).to have_css( + "[data-admin--work-packages-identifier-target=autofixSection][hidden]" + ) + click_button I18n.t("button_save") + + expect(page).to have_current_path(settings_path) + expect(page).to have_no_css("[role=alertdialog]") + end + end + + context "when switching to alphanumeric" do + before do + visit_settings + choose I18n.t("setting_work_packages_identifier_alphanumeric") + end + + it "shows the autofix section after selecting alphanumeric" do + expect(page).to have_css( + "[data-admin--work-packages-identifier-target=autofixSection]:not([hidden])", + visible: :visible + ) + end + + it "opens the confirmation dialog when 'Autofix and save' is clicked" do + click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") + + expect(page).to have_css("[role=alertdialog]", visible: :visible) + end + + it "shows the dialog heading and checkbox" do + click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") + + within("[role=alertdialog]") do + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.dialog.heading")) + expect(page).to have_field( + I18n.t("admin.settings.work_packages_identifier.dialog.checkbox_label"), + type: :checkbox + ) + end + end + + it "enables the confirm button only after checking the checkbox" do + click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") + + within("[role=alertdialog]") do + confirm_text = I18n.t("admin.settings.work_packages_identifier.dialog.confirm_button") + + expect(page).to have_button(confirm_text, disabled: true) + + check I18n.t("admin.settings.work_packages_identifier.dialog.checkbox_label") + + expect(page).to have_button(confirm_text, disabled: false) + end + end + + it "hides the plain Save button when autofix section is visible" do + expect(page).to have_no_button(I18n.t("button_save")) + expect(page).to have_button( + I18n.t("admin.settings.work_packages_identifier.button_autofix"), + disabled: false + ) + end + end + end +end From 180a17a99aedc8e7332782213fbd4201903aa913 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 5 Mar 2026 11:53:18 +0300 Subject: [PATCH 023/435] Extend handle generator with in_use/reserved reasons and performance-aware preview --- .../identifier_autofix_section_component.rb | 15 +- ...dentifier_settings_form_component.html.erb | 5 +- .../identifier_settings_form_component.rb | 30 +++- .../project_handle_suggestion_generator.rb | 138 ++++-------------- config/locales/en.yml | 7 +- ...entifier_autofix_section_component_spec.rb | 45 ++++++ ...roject_handle_suggestion_generator_spec.rb | 101 ++++++++----- 7 files changed, 181 insertions(+), 160 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb index 57b206282c6..126858079e9 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb @@ -36,13 +36,11 @@ module WorkPackages DISPLAY_COUNT = 5 - # projects_data: array of hashes from ProjectHandleSuggestionGenerator - # Each hash: { project:, current_identifier:, suggested_handle:, error_reason: } - def initialize(projects_data:) + def initialize(projects_data:, total_count: projects_data.size) super() - @total_count = projects_data.size + @total_count = total_count @displayed = projects_data.first(DISPLAY_COUNT) - @remaining_count = [projects_data.size - DISPLAY_COUNT, 0].max + @remaining_count = [total_count - @displayed.size, 0].max end private @@ -55,9 +53,10 @@ module WorkPackages I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_too_long") when :special_characters I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_special_characters") - # FIXME(project_handles): Add when :handle_reserved and :identifier_taken - # with their respective i18n keys (error_handle_reserved / error_identifier_taken) - # once the model and final copy are confirmed. + when :in_use + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_in_use") + when :reserved + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_reserved") end end diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index c05c75311c5..fa11fa3763f 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -73,7 +73,10 @@ ) do %> <%= render( - WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent.new(projects_data:) + WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent.new( + projects_data:, + total_count: + ) ) %> <% end %> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index f996144c144..7a6d597a123 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -35,15 +35,39 @@ module WorkPackages class IdentifierSettingsFormComponent < ApplicationComponent include OpPrimer::FormHelpers - attr_reader :projects_data + attr_reader :projects_data, :total_count def initialize super - @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call + # FIXME: Replace WHERE clause with: + # Project.where.not(id: OldProjectIdentifier.where(current: true).select(:project_id)) + # once all valid identifiers have been migrated to handle rows. + problematic_scope = Project.where( + "length(identifier) > ? OR identifier ~ ?", + WorkPackages::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH, + "[^a-zA-Z0-9]" + ) + + @total_count = problematic_scope.count + preview_projects = problematic_scope + .select(:id, :name, :identifier) + .limit(WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT) + .to_a + + in_use_handles = Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set + # TODO: Replace with OldProjectIdentifier.pluck(:identifier).to_set + # once the OldProjectIdentifier model and migration are added. + reserved_handles = Set.new + + @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call( + preview_projects, + in_use_handles:, + reserved_handles: + ) end def has_problematic_projects? - projects_data.any? + total_count > 0 end private diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb index 791c70d634b..7ed495ab256 100644 --- a/app/services/work_packages/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/project_handle_suggestion_generator.rb @@ -29,106 +29,39 @@ #++ module WorkPackages - # Scans projects for identifiers that do not meet alphanumeric handle - # requirements and generates a short uppercase acronym suggestion for each one. - # - # A "problematic" identifier is one that (error_reason): - # - is longer than HANDLE_MAX_LENGTH (10) characters → :too_long - # - contains any character outside [a-zA-Z0-9] → :special_characters - # FIXME(project_handles): Two further cases once model exists: - # - the identifier string is already in project_handles for - # another project (current or historical) → :handle_reserved - # - the identifier is valid format but will be auto-adopted - # as another project's handle during migration → :identifier_taken + # Generates a short uppercase acronym suggestion for each given project. # # The suggestion is derived from the project name: taking the first letter of # each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two # projects produce the same acronym, a numeric suffix resolves the collision # ("SC", "SC2", "SC3", …). # - # FIXME(project_handles): This class currently reads from the existing - # Project#identifier column. Once the project_handles data model is available, - # replace #call with a query that finds projects with no current handle row: + # Each result entry includes an error_reason classifying why the project's + # current identifier is problematic: + # - :too_long — identifier length exceeds HANDLE_MAX_LENGTH + # - :special_characters — identifier contains characters outside [a-zA-Z0-9] + # - :in_use — identifier is another project's active handle + # - :reserved — identifier appears in another project's handle history # - # Project - # .select(:id, :name, :identifier) - # .where.not(id: ProjectHandle.where(current: true).select(:project_id)) - # .to_a - # .then { |projects_without_handle| generate_suggestions(projects_without_handle) } - # - # project_handles stores only valid (alphanumeric, ≤ HANDLE_MAX_LENGTH) handles. - # A project with no current handle is one whose Project#identifier has not yet been - # migrated; generate_suggestions still uses Project#identifier as current_identifier - # and error_reason still classifies why that identifier is problematic. - # The :current boolean marks the live handle; superseded handles are retained so - # that existing URLs continue to resolve via redirect. class ProjectHandleSuggestionGenerator - HANDLE_MAX_LENGTH = 10 + HANDLE_MAX_LENGTH = 5 FALLBACK_HANDLE = "PROJ" - # Upper bound for suffix counter — prevents an infinite loop if used_handles - # is somehow saturated. 10 000 projects sharing an acronym is unreachable in - # practice; raising here indicates a serious bug in the caller's pre-seeding. SUFFIX_LIMIT = 10_000 - # @return [Array] one entry per project with a problematic identifier: - # { project:, current_identifier:, suggested_handle:, error_reason: } - # error_reason is :too_long, :special_characters, - # :handle_reserved, or :identifier_taken (last two: FIXME project_handles) - def self.call - new.call + def self.call(projects, reserved_handles: Set.new, in_use_handles: Set.new) + new.call(projects, reserved_handles:, in_use_handles:) end - def call - # FIXME(project_handles): Replace with projects lacking a current handle — see class doc above. - # Note: the future query (Project.where.not(id: ProjectHandle.where(current:true)…)) - # also naturally surfaces :handle_reserved and :identifier_taken projects because - # they too will have no valid current handle row. No extra WHERE filter is needed; - # the key change is pre-seeding used_handles in generate_suggestions (see below). - Project - .select(:id, :name, :identifier) - .where("length(identifier) > ? OR identifier ~ ?", HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]") - .to_a - .then { generate_suggestions(it) } + def call(projects, reserved_handles:, in_use_handles:) + generate_suggestions(projects, reserved_handles:, in_use_handles:) end private - def error_reason(identifier) - if identifier.length > HANDLE_MAX_LENGTH - :too_long - else - :special_characters - end - # FIXME(project_handles): Add two further branches (checked after the above): - # - # :handle_reserved — identifier already in project_handles for another - # project (any row, current or historical) - # :identifier_taken — identifier is valid format and will be auto-adopted - # as another project's handle during migration - # - # Priority order: :too_long > :special_characters > - # :handle_reserved > :identifier_taken - end - - # Builds the suggestion list for a set of problematic projects. - # Handles are generated in iteration order; duplicates are resolved in-place - # so the final list is guaranteed to contain no two identical handles. - def generate_suggestions(projects) - # FIXME(project_handles): Pre-seed used_handles from the DB before iterating - # so suggestions never collide with handles already in use across all projects: - # - # used_handles.merge(ProjectHandle.pluck(:handle)) - # # ^ every handle ever assigned to any project (current + historical) - # # → prevents :handle_reserved conflicts - # - # used_handles.merge( - # Project.where("length(identifier) <= ? AND identifier ~ ?", - # HANDLE_MAX_LENGTH, "^[A-Za-z0-9]+$") - # .pluck(:identifier) - # ) - # # ^ valid-format identifiers that will be auto-adopted as handles - # # → prevents :identifier_taken conflicts + def generate_suggestions(projects, reserved_handles:, in_use_handles:) used_handles = Set.new + used_handles.merge(in_use_handles) + used_handles.merge(reserved_handles) projects.map do |project| base = handle_from_name(project.name) @@ -139,27 +72,18 @@ module WorkPackages project:, current_identifier: project.identifier, suggested_handle: handle, - error_reason: error_reason(project.identifier) + error_reason: error_reason(project.identifier, reserved_handles:, in_use_handles:) } end end - # Derives a short uppercase handle from the project name by taking the - # first letter of each word (acronym style): - # "Flight Planning Algorithm" → "FPA" - # "Fly & Sky" → "FS" - # "Cécile Martin" → "CM" (accented letters treated as one word) - # Falls back to "PROJ" when the name yields no usable initials. - # Result is truncated to HANDLE_MAX_LENGTH characters. def handle_from_name(name) # Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside # their word rather than treated as separators by the ASCII-only [a-zA-Z]. words = name.to_s.scan(/[[:alpha:][:digit:]]+/) return FALLBACK_HANDLE if words.empty? - # Transliterate each word's first character to ASCII (é→e, ñ→n) then - # upcase. filter_map silently drops any initial that yields nothing useful - # after transliteration (e.g. a lone ideograph that maps to "?"). + # Transliterate each word's first character to ASCII (é→e, ñ→n) then upcase. acronym = words.filter_map do |word| ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] ch if ch&.match?(/\A[A-Z0-9]\z/) @@ -170,25 +94,9 @@ module WorkPackages acronym.slice(0, HANDLE_MAX_LENGTH) end - # Ensures the returned handle is unique within the current batch by appending - # an incrementing numeric suffix when the base acronym is already taken. - # - # Examples (HANDLE_MAX_LENGTH = 10): - # unique_handle("SC", Set["SC"]) → "SC2" - # unique_handle("SC", Set["SC", "SC2"]) → "SC3" - # unique_handle("ABCDEFGHIJ", Set["ABCDEFGHIJ"]) → "ABCDEFGHI2" - # (base is trimmed so base + suffix ≤ HANDLE_MAX_LENGTH) - # - # @param base [String] the acronym to start from (already ≤ HANDLE_MAX_LENGTH) - # @param used_handles [Set] handles already assigned in this batch - # @return [String] a unique handle ≤ HANDLE_MAX_LENGTH - # @raise [RuntimeError] if no unique candidate is found within SUFFIX_LIMIT def unique_handle(base, used_handles) - # Fast path: acronym is unique, no suffix needed. return base unless used_handles.include?(base) - # Slow path: append "2", "3", … trimming the base as needed so the result - # never exceeds HANDLE_MAX_LENGTH characters. counter = 2 loop do raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ @@ -201,5 +109,17 @@ module WorkPackages counter += 1 end end + + def error_reason(identifier, reserved_handles:, in_use_handles:) + if identifier.length > HANDLE_MAX_LENGTH + :too_long + elsif identifier.match?(/[^a-zA-Z0-9]/) + :special_characters + elsif in_use_handles.include?(identifier) + :in_use + elsif reserved_handles.include?(identifier) + :reserved + end + end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index facff3dbf4b..5ec556c1742 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -222,11 +222,10 @@ en: label_autofixed_suggestion: Autofixed suggestion label_example_work_package_id: Example work package ID autofix_preview: - error_too_long: Has to be fewer than 10 characters + error_too_long: Has to be fewer than 5 characters error_special_characters: Special characters not allowed - # FIXME(project_handles): Confirm final copy for these two labels - error_handle_reserved: Handle reserved by another project - error_identifier_taken: Identifier already used by another project + error_in_use: Already in use as another project's active handle + error_reserved: Reserved by another project's handle history remaining_projects: one: "... 1 more project" other: "... %{count} more projects" diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb index da36555059c..65e015284dd 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb @@ -159,4 +159,49 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent, expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.box_header.label_example_work_package_id")) end end + + context "with :in_use and :reserved error reasons" do + let(:entry_in_use) do + build_entry(name: "Alpha Beta Corp", identifier: "ABC", handle: "AB2", error_reason: :in_use) + end + let(:entry_reserved) do + build_entry(name: "Delta Echo Foxtrot", identifier: "DEF", handle: "DE2", error_reason: :reserved) + end + let(:projects_data) { [entry_in_use, entry_reserved] } + + it "shows the in_use error caption" do + render_inline(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_in_use")) + end + + it "shows the reserved error caption" do + render_inline(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_reserved")) + end + end + + context "with total_count passed separately" do + let(:projects_data) do + Array.new(3) do |i| + build_entry(name: "Project #{i}", identifier: "proj-#{i}", handle: "P#{i}", error_reason: :special_characters) + end + end + + subject(:component) { described_class.new(projects_data:, total_count: 50) } + + it "reflects the real total in the banner" do + render_inline(component) + expect(page).to have_text( + I18n.t( + "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", + project_count: 50 + ) + ) + end + + it "shows remaining count based on total_count, not projects_data.size" do + render_inline(component) + expect(page).to have_text("47 more projects") + end + end end diff --git a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb index 2360fc4dcdf..6a9ca3a4edc 100644 --- a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/project_handle_suggestion_generator_spec.rb @@ -30,14 +30,11 @@ require "rails_helper" -# Integration-style spec: real Project records are created in the test DB so that -# both the SQL query (WHERE length(identifier) > 10 OR identifier ~ '[^a-zA-Z0-9]') -# and the suggestion algorithm are exercised end-to-end. RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do describe ".call" do - context "when all existing projects have valid identifiers" do + context "when given an empty array" do it "returns an empty array" do - expect(described_class.call).to be_empty + expect(described_class.call([])).to be_empty end end @@ -45,21 +42,23 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do shared_let(:project) { create(:project, identifier: "verylongidentifier", name: "Very Long Identifier") } it "returns one suggestion entry for the project" do - result = described_class.call + result = described_class.call([project]) expect(result.size).to eq(1) expect(result.first[:project]).to eq(project) expect(result.first[:current_identifier]).to eq("verylongidentifier") expect(result.first[:error_reason]).to eq(:too_long) expect(result.first[:suggested_handle]).to be_present - expect(result.first[:suggested_handle].length).to be <= 10 + expect(result.first[:suggested_handle].length).to be <= described_class::HANDLE_MAX_LENGTH end end context "when a project has a special-character identifier" do - shared_let(:project) { create(:project, identifier: "fly-sky", name: "Fly Sky") } + # "fs" is 2 chars (≤ HANDLE_MAX_LENGTH) but contains no special chars; + # use "f-s" (3 chars ≤ HANDLE_MAX_LENGTH) to trigger :special_characters. + shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") } it "returns a suggestion entry with error_reason :special_characters" do - result = described_class.call + result = described_class.call([project]) expect(result.size).to eq(1) expect(result.first[:error_reason]).to eq(:special_characters) end @@ -70,12 +69,12 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") } it "generates unique handles for each project" do - handles = described_class.call.pluck(:suggested_handle) + handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle) expect(handles.uniq.size).to eq(handles.size) end it "appends a numeric suffix to resolve conflicts" do - handles = described_class.call.pluck(:suggested_handle) + handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle) expect(handles).to include("SC") expect(handles.any? { |h| h.match?(/\ASC\d+\z/) }).to be true end @@ -83,15 +82,13 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do end describe "handle generation from project name" do - # Each example creates one project whose identifier has a hyphen (special char) - # so the SQL query picks it up, then asserts the suggested handle from the name. { "Flight Planning Algorithm" => "FPA", "Fly & Sky" => "FS", "Social media marketing" => "SMM", "Arcanos (mobile-web-app)" => "AMWA", "Flight Planning Training" => "FPT", - "A B C D E F G H I J K" => "ABCDEFGHIJ", # truncated to 10 chars + "A B C D E F G H I J K" => "ABCDE", # truncated to HANDLE_MAX_LENGTH (5) "Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"] "étude de cas" => "EDC", # Unicode: é→E via transliteration # Non-Latin scripts have no transliteration entries (I18n.transliterate → "?"). @@ -100,54 +97,88 @@ RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do "Plan 日本" => "P" # Mixed: Latin "P" survives; "日" is dropped }.each do |project_name, expected_handle| it "generates '#{expected_handle}' from '#{project_name}'" do - create(:project, identifier: "bad-id", name: project_name) - expect(described_class.call.first[:suggested_handle]).to eq(expected_handle) + project = create(:project, identifier: "bad-id", name: project_name) + expect(described_class.call([project]).first[:suggested_handle]).to eq(expected_handle) end end end describe "unique_handle conflict resolution" do it "uses the base handle when not yet taken" do - create(:project, identifier: "sc-app", name: "Stream Communicator") - expect(described_class.call.first[:suggested_handle]).to eq("SC") + project = create(:project, identifier: "sc-app", name: "Stream Communicator") + expect(described_class.call([project]).first[:suggested_handle]).to eq("SC") end it "appends '2' when the base is already taken" do - create(:project, identifier: "sc-app", name: "Stream Communicator") - create(:project, identifier: "stream-ch", name: "Stream Channel") - expect(described_class.call.pluck(:suggested_handle)).to contain_exactly("SC", "SC2") + p1 = create(:project, identifier: "sc-app", name: "Stream Communicator") + p2 = create(:project, identifier: "stream-ch", name: "Stream Channel") + expect(described_class.call([p1, p2]).pluck(:suggested_handle)).to contain_exactly("SC", "SC2") end it "increments the suffix until unique" do - create(:project, identifier: "sc-a", name: "Stream Communicator") - create(:project, identifier: "sc-b", name: "Stream Channel") - create(:project, identifier: "sc-c", name: "Something Cool") - expect(described_class.call.pluck(:suggested_handle)).to contain_exactly("SC", "SC2", "SC3") + p1 = create(:project, identifier: "sc-a", name: "Stream Communicator") + p2 = create(:project, identifier: "sc-b", name: "Stream Channel") + p3 = create(:project, identifier: "sc-c", name: "Something Cool") + expect(described_class.call([p1, p2, p3]).pluck(:suggested_handle)).to contain_exactly("SC", "SC2", "SC3") end it "trims the base to fit within HANDLE_MAX_LENGTH when adding a suffix" do - create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") - create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") - handles = described_class.call.pluck(:suggested_handle) - expect(handles.all? { |h| h.length <= 10 }).to be true + p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") + p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") + handles = described_class.call([p1, p2]).pluck(:suggested_handle) + expect(handles.all? { |h| h.length <= described_class::HANDLE_MAX_LENGTH }).to be true expect(handles.uniq.size).to eq(2) end + + it "does not suggest a handle that is already in use (pre-seeded collision)" do + # "SC" is pre-seeded as an in-use handle; the generator must skip it and use "SC2". + project = create(:project, identifier: "sc-app", name: "Stream Communicator") + result = described_class.call([project], in_use_handles: Set["SC"]) + expect(result.first[:suggested_handle]).not_to eq("SC") + expect(result.first[:suggested_handle]).to match(/\ASC\d+\z/) + end end describe "error reason assignment" do it "assigns :too_long when identifier length exceeds HANDLE_MAX_LENGTH" do - create(:project, identifier: "verylongidentifier", name: "Test") - expect(described_class.call.first[:error_reason]).to eq(:too_long) + project = create(:project, identifier: "verylongidentifier", name: "Test") + expect(described_class.call([project]).first[:error_reason]).to eq(:too_long) end it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do - create(:project, identifier: "my-project", name: "Test") - expect(described_class.call.first[:error_reason]).to eq(:special_characters) + project = create(:project, identifier: "ab-c", name: "Test") + expect(described_class.call([project]).first[:error_reason]).to eq(:special_characters) end it "assigns :too_long (priority) when identifier is both too long and has special chars" do - create(:project, identifier: "my-very-long-identifier", name: "Test") - expect(described_class.call.first[:error_reason]).to eq(:too_long) + project = create(:project, identifier: "my-very-long-identifier", name: "Test") + expect(described_class.call([project]).first[:error_reason]).to eq(:too_long) + end + + it "assigns :in_use when identifier is another project's active handle" do + # "abc" is valid (lowercase alphanumeric, ≤ 5 chars, no special chars) + project = create(:project, identifier: "abc", name: "Alpha Beta Corp") + result = described_class.call([project], in_use_handles: Set["abc"]) + expect(result.first[:error_reason]).to eq(:in_use) + end + + it "assigns :reserved when identifier appears in historical handles" do + project = create(:project, identifier: "abc", name: "Alpha Beta Corp") + result = described_class.call([project], reserved_handles: Set["abc"]) + expect(result.first[:error_reason]).to eq(:reserved) + end + + it "prefers :in_use over :reserved when identifier is in both sets" do + project = create(:project, identifier: "abc", name: "Alpha Beta Corp") + result = described_class.call([project], in_use_handles: Set["abc"], reserved_handles: Set["abc"]) + expect(result.first[:error_reason]).to eq(:in_use) + end + + it "prefers :too_long over :in_use when identifier is also too long" do + # "toolong" is 7 chars (> HANDLE_MAX_LENGTH=5) and alphanumeric — too_long wins + project = create(:project, identifier: "toolong", name: "Too Long Handle") + result = described_class.call([project], in_use_handles: Set["toolong"]) + expect(result.first[:error_reason]).to eq(:too_long) end end end From b54d9dccc18c6b92e411423deeb7791e7ae03655 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 5 Mar 2026 11:58:13 +0300 Subject: [PATCH 024/435] Remove excess comments --- ...dentifier_settings_form_component.html.erb | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index fa11fa3763f..293a10a6cd3 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -29,30 +29,12 @@ ++# %> -<%# - Layout: - [outer div — Stimulus controller, full width] - [settings_primer_form_with — 680px wrapper, radio buttons only] - ... - [full-width autofix section — banner + table, Stimulus target] - [op-admin-settings-form-wrapper — 680px, submit buttons] - "Save" — type: :submit, visible when autofix section is hidden - "Autofix & save" — type: :button, visible when autofix section is shown; - opens the confirmation dialog via Stimulus before POSTing - [change-identifiers-dialog — DangerDialog, opened by Stimulus on confirm] - - Two separate buttons are rendered (each fully Primer-accessible) rather than - overwriting textContent on a single button, which would purge Primer's inner - accessibility spans/aria nodes. -%> - <%= tag.div( data: { controller: "admin--work-packages-identifier", admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects? } ) do %> - <%# Constrained-width form: radio buttons only (no submit inside the form element) %> <%= render( WorkPackages::Admin::Settings::IdentifierRadioFormComponent.new( @@ -66,7 +48,6 @@ ) %> - <%# Full-width autofix section — outside the 680px settings_primer_form_with wrapper %> <%= tag.div( hidden: !show_autofix_section?, data: { admin__work_packages_identifier_target: "autofixSection" } @@ -81,9 +62,6 @@ %> <% end %> - <%# Submit buttons: constrained to 680px %> - <%# "Save" — direct submit, shown when there is no autofix section %> - <%# "Autofix and save" — opens dialog, shown when autofix section is visible %>
<%= render( @@ -110,7 +88,5 @@ ) { t("admin.settings.work_packages_identifier.button_autofix") } %>
- - <%# Confirmation dialog — opened by Stimulus when "Autofix and save" is clicked %> <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> <% end %> From f1655e953c34ac77167ac402730e258603385166 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 5 Mar 2026 12:24:27 +0300 Subject: [PATCH 025/435] Extract PreviewQuery, collapse error_label, remove unused ERROR_REASONS --- .../identifier_autofix_section_component.rb | 11 +- .../identifier_settings_form_component.rb | 28 +---- .../identifier_autofix/preview_query.rb | 76 +++++++++++++ ...entifier_autofix_section_component_spec.rb | 44 -------- .../identifier_autofix/preview_query_spec.rb | 104 ++++++++++++++++++ 5 files changed, 184 insertions(+), 79 deletions(-) create mode 100644 app/services/work_packages/identifier_autofix/preview_query.rb create mode 100644 spec/services/work_packages/identifier_autofix/preview_query_spec.rb diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb index 126858079e9..b2484c35dd0 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb @@ -48,16 +48,7 @@ module WorkPackages attr_reader :total_count, :displayed, :remaining_count def error_label(error_reason) - case error_reason - when :too_long - I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_too_long") - when :special_characters - I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_special_characters") - when :in_use - I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_in_use") - when :reserved - I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_reserved") - end + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_#{error_reason}") end # Produces a realistic-looking example work package ID for the preview table. diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index 7a6d597a123..0e6297ca6cf 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -39,31 +39,9 @@ module WorkPackages def initialize super - # FIXME: Replace WHERE clause with: - # Project.where.not(id: OldProjectIdentifier.where(current: true).select(:project_id)) - # once all valid identifiers have been migrated to handle rows. - problematic_scope = Project.where( - "length(identifier) > ? OR identifier ~ ?", - WorkPackages::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH, - "[^a-zA-Z0-9]" - ) - - @total_count = problematic_scope.count - preview_projects = problematic_scope - .select(:id, :name, :identifier) - .limit(WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT) - .to_a - - in_use_handles = Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set - # TODO: Replace with OldProjectIdentifier.pluck(:identifier).to_set - # once the OldProjectIdentifier model and migration are added. - reserved_handles = Set.new - - @projects_data = WorkPackages::ProjectHandleSuggestionGenerator.call( - preview_projects, - in_use_handles:, - reserved_handles: - ) + result = WorkPackages::IdentifierAutofix::PreviewQuery.new.call + @projects_data = result.projects_data + @total_count = result.total_count end def has_problematic_projects? diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb new file mode 100644 index 00000000000..670bdd919f2 --- /dev/null +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module IdentifierAutofix + class PreviewQuery + Result = Data.define(:projects_data, :total_count) + + def call + total = problematic_scope.count + preview = problematic_scope + .select(:id, :name, :identifier) + .limit(WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT) + .to_a + + suggestions = WorkPackages::ProjectHandleSuggestionGenerator.call( + preview, + in_use_handles:, + reserved_handles: + ) + + Result.new(projects_data: suggestions, total_count: total) + end + + private + + # FIXME: Replace WHERE clause with: + # Project.where.not(id: OldProjectIdentifier.where(current: true).select(:project_id)) + # once all valid identifiers have been migrated to handle rows. + def problematic_scope + @problematic_scope ||= Project.where( + "length(identifier) > ? OR identifier ~ ?", + WorkPackages::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH, + "[^a-zA-Z0-9]" + ) + end + + def in_use_handles + Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set + end + + def reserved_handles + # TODO: OldProjectIdentifier.pluck(:identifier).to_set + # once the OldProjectIdentifier model and migration are added. + Set.new + end + end + end +end diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb index 65e015284dd..36e1bc97607 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb @@ -160,48 +160,4 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent, end end - context "with :in_use and :reserved error reasons" do - let(:entry_in_use) do - build_entry(name: "Alpha Beta Corp", identifier: "ABC", handle: "AB2", error_reason: :in_use) - end - let(:entry_reserved) do - build_entry(name: "Delta Echo Foxtrot", identifier: "DEF", handle: "DE2", error_reason: :reserved) - end - let(:projects_data) { [entry_in_use, entry_reserved] } - - it "shows the in_use error caption" do - render_inline(component) - expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_in_use")) - end - - it "shows the reserved error caption" do - render_inline(component) - expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_reserved")) - end - end - - context "with total_count passed separately" do - let(:projects_data) do - Array.new(3) do |i| - build_entry(name: "Project #{i}", identifier: "proj-#{i}", handle: "P#{i}", error_reason: :special_characters) - end - end - - subject(:component) { described_class.new(projects_data:, total_count: 50) } - - it "reflects the real total in the banner" do - render_inline(component) - expect(page).to have_text( - I18n.t( - "admin.settings.work_packages_identifier.banner.existing_identifiers_notice", - project_count: 50 - ) - ) - end - - it "shows remaining count based on total_count, not projects_data.size" do - render_inline(component) - expect(page).to have_text("47 more projects") - end - end end diff --git a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb new file mode 100644 index 00000000000..66fcfa7ab35 --- /dev/null +++ b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do + subject(:result) { described_class.new.call } + + let(:display_count) { WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT } + + def create_problematic_project(name:, identifier:) + create(:project, name:, identifier:) + end + + def create_valid_project(name:, identifier:) + create(:project, name:, identifier:) + end + + context "when there are no problematic projects" do + before { create_valid_project(name: "Clean Project", identifier: "clean") } + + it "returns total_count 0 and empty projects_data" do + expect(result.total_count).to eq(0) + expect(result.projects_data).to be_empty + end + end + + context "when there are fewer than DISPLAY_COUNT problematic projects" do + let!(:problematic) do + [ + create_problematic_project(name: "Flight Planning", identifier: "flight-planning"), + create_problematic_project(name: "Very Long Name Project", identifier: "verylongnameproject") + ] + end + + it "returns all of them in projects_data" do + expect(result.projects_data.size).to eq(2) + end + + it "returns the correct total_count" do + expect(result.total_count).to eq(2) + end + end + + context "when there are more than DISPLAY_COUNT problematic projects" do + let!(:problematic) do + Array.new(display_count + 3) do |i| + create_problematic_project(name: "Project #{i}", identifier: "proj-#{i}") + end + end + + it "returns only DISPLAY_COUNT entries in projects_data" do + expect(result.projects_data.size).to eq(display_count) + end + + it "returns the full total_count (not capped at DISPLAY_COUNT)" do + expect(result.total_count).to eq(display_count + 3) + end + end + + context "when two problematic projects produce the same base acronym" do + let!(:first_project) { create_problematic_project(name: "Flight Planning", identifier: "flight-planning") } + let!(:second_project) { create_problematic_project(name: "Foxtrot Papa", identifier: "foxtrot-papa") } + + it "does not assign the same handle to both" do + handles = result.projects_data.pluck(:suggested_handle) + expect(handles.uniq.size).to eq(handles.size) + end + end + + it "returns Result entries shaped like generator output" do + create_problematic_project(name: "Alpha Beta", identifier: "alpha-beta") + + entry = result.projects_data.first + expect(entry).to include(:project, :current_identifier, :suggested_handle, :error_reason) + end +end From 4701835187ed120ec4142c815a717f1696343e00 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 5 Mar 2026 12:26:13 +0300 Subject: [PATCH 026/435] Relocate ProjectHandleSuggestionGenerator into WorkPackages::IdentifierAutofix --- .../identifier_autofix/preview_query.rb | 4 +- .../project_handle_suggestion_generator.rb | 127 ++++++++++++++++++ .../project_handle_suggestion_generator.rb | 125 ----------------- ...roject_handle_suggestion_generator_spec.rb | 2 +- 4 files changed, 130 insertions(+), 128 deletions(-) create mode 100644 app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb delete mode 100644 app/services/work_packages/project_handle_suggestion_generator.rb rename spec/services/work_packages/{ => identifier_autofix}/project_handle_suggestion_generator_spec.rb (99%) diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb index 670bdd919f2..5cbf48d42e5 100644 --- a/app/services/work_packages/identifier_autofix/preview_query.rb +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -40,7 +40,7 @@ module WorkPackages .limit(WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT) .to_a - suggestions = WorkPackages::ProjectHandleSuggestionGenerator.call( + suggestions = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.call( preview, in_use_handles:, reserved_handles: @@ -57,7 +57,7 @@ module WorkPackages def problematic_scope @problematic_scope ||= Project.where( "length(identifier) > ? OR identifier ~ ?", - WorkPackages::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH, + WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH, "[^a-zA-Z0-9]" ) end diff --git a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb new file mode 100644 index 00000000000..a509cb2fa1f --- /dev/null +++ b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module IdentifierAutofix + # Generates a short uppercase acronym suggestion for each given project. + # + # The suggestion is derived from the project name: taking the first letter of + # each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two + # projects produce the same acronym, a numeric suffix resolves the collision + # ("SC", "SC2", "SC3", …). + # + # Each result entry includes an error_reason classifying why the project's + # current identifier is problematic: + # - :too_long — identifier length exceeds HANDLE_MAX_LENGTH + # - :special_characters — identifier contains characters outside [a-zA-Z0-9] + # - :in_use — identifier is another project's active handle + # - :reserved — identifier appears in another project's handle history + # + class ProjectHandleSuggestionGenerator + HANDLE_MAX_LENGTH = 5 + FALLBACK_HANDLE = "PROJ" + SUFFIX_LIMIT = 10_000 + + def self.call(projects, reserved_handles: Set.new, in_use_handles: Set.new) + new.call(projects, reserved_handles:, in_use_handles:) + end + + def call(projects, reserved_handles:, in_use_handles:) + generate_suggestions(projects, reserved_handles:, in_use_handles:) + end + + private + + def generate_suggestions(projects, reserved_handles:, in_use_handles:) + used_handles = Set.new + used_handles.merge(in_use_handles) + used_handles.merge(reserved_handles) + + projects.map do |project| + base = handle_from_name(project.name) + handle = unique_handle(base, used_handles) + used_handles << handle + + { + project:, + current_identifier: project.identifier, + suggested_handle: handle, + error_reason: error_reason(project.identifier, reserved_handles:, in_use_handles:) + } + end + end + + def handle_from_name(name) + # Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside + # their word rather than treated as separators by the ASCII-only [a-zA-Z]. + words = name.to_s.scan(/[[:alpha:][:digit:]]+/) + return FALLBACK_HANDLE if words.empty? + + # Transliterate each word's first character to ASCII (é→e, ñ→n) then upcase. + acronym = words.filter_map do |word| + ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] + ch if ch&.match?(/\A[A-Z0-9]\z/) + end.join + + return FALLBACK_HANDLE if acronym.empty? + + acronym.slice(0, HANDLE_MAX_LENGTH) + end + + def unique_handle(base, used_handles) + return base unless used_handles.include?(base) + + counter = 2 + loop do + raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ + if counter > SUFFIX_LIMIT + + suffix = counter.to_s + candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}" + break candidate unless used_handles.include?(candidate) + + counter += 1 + end + end + + def error_reason(identifier, reserved_handles:, in_use_handles:) + if identifier.length > HANDLE_MAX_LENGTH + :too_long + elsif identifier.match?(/[^a-zA-Z0-9]/) + :special_characters + elsif in_use_handles.include?(identifier) + :in_use + elsif reserved_handles.include?(identifier) + :reserved + end + end + end + end +end diff --git a/app/services/work_packages/project_handle_suggestion_generator.rb b/app/services/work_packages/project_handle_suggestion_generator.rb deleted file mode 100644 index 7ed495ab256..00000000000 --- a/app/services/work_packages/project_handle_suggestion_generator.rb +++ /dev/null @@ -1,125 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module WorkPackages - # Generates a short uppercase acronym suggestion for each given project. - # - # The suggestion is derived from the project name: taking the first letter of - # each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two - # projects produce the same acronym, a numeric suffix resolves the collision - # ("SC", "SC2", "SC3", …). - # - # Each result entry includes an error_reason classifying why the project's - # current identifier is problematic: - # - :too_long — identifier length exceeds HANDLE_MAX_LENGTH - # - :special_characters — identifier contains characters outside [a-zA-Z0-9] - # - :in_use — identifier is another project's active handle - # - :reserved — identifier appears in another project's handle history - # - class ProjectHandleSuggestionGenerator - HANDLE_MAX_LENGTH = 5 - FALLBACK_HANDLE = "PROJ" - SUFFIX_LIMIT = 10_000 - - def self.call(projects, reserved_handles: Set.new, in_use_handles: Set.new) - new.call(projects, reserved_handles:, in_use_handles:) - end - - def call(projects, reserved_handles:, in_use_handles:) - generate_suggestions(projects, reserved_handles:, in_use_handles:) - end - - private - - def generate_suggestions(projects, reserved_handles:, in_use_handles:) - used_handles = Set.new - used_handles.merge(in_use_handles) - used_handles.merge(reserved_handles) - - projects.map do |project| - base = handle_from_name(project.name) - handle = unique_handle(base, used_handles) - used_handles << handle - - { - project:, - current_identifier: project.identifier, - suggested_handle: handle, - error_reason: error_reason(project.identifier, reserved_handles:, in_use_handles:) - } - end - end - - def handle_from_name(name) - # Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside - # their word rather than treated as separators by the ASCII-only [a-zA-Z]. - words = name.to_s.scan(/[[:alpha:][:digit:]]+/) - return FALLBACK_HANDLE if words.empty? - - # Transliterate each word's first character to ASCII (é→e, ñ→n) then upcase. - acronym = words.filter_map do |word| - ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] - ch if ch&.match?(/\A[A-Z0-9]\z/) - end.join - - return FALLBACK_HANDLE if acronym.empty? - - acronym.slice(0, HANDLE_MAX_LENGTH) - end - - def unique_handle(base, used_handles) - return base unless used_handles.include?(base) - - counter = 2 - loop do - raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ - if counter > SUFFIX_LIMIT - - suffix = counter.to_s - candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}" - break candidate unless used_handles.include?(candidate) - - counter += 1 - end - end - - def error_reason(identifier, reserved_handles:, in_use_handles:) - if identifier.length > HANDLE_MAX_LENGTH - :too_long - elsif identifier.match?(/[^a-zA-Z0-9]/) - :special_characters - elsif in_use_handles.include?(identifier) - :in_use - elsif reserved_handles.include?(identifier) - :reserved - end - end - end -end diff --git a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb similarity index 99% rename from spec/services/work_packages/project_handle_suggestion_generator_spec.rb rename to spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb index 6a9ca3a4edc..37a98e1cd18 100644 --- a/spec/services/work_packages/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe WorkPackages::ProjectHandleSuggestionGenerator do +RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator do describe ".call" do context "when given an empty array" do it "returns an empty array" do From 1ef8ee63801f9ac533d339eed216fd85de74486f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 5 Mar 2026 12:38:30 +0300 Subject: [PATCH 027/435] Touch up project handle suggestion generator specs --- .../project_handle_suggestion_generator_spec.rb | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb index 37a98e1cd18..afead32f00e 100644 --- a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb @@ -76,7 +76,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator it "appends a numeric suffix to resolve conflicts" do handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle) expect(handles).to include("SC") - expect(handles.any? { |h| h.match?(/\ASC\d+\z/) }).to be true + expect(handles.any? { it.match?(/\ASC\d+\z/) }).to be true end end end @@ -109,12 +109,6 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator expect(described_class.call([project]).first[:suggested_handle]).to eq("SC") end - it "appends '2' when the base is already taken" do - p1 = create(:project, identifier: "sc-app", name: "Stream Communicator") - p2 = create(:project, identifier: "stream-ch", name: "Stream Channel") - expect(described_class.call([p1, p2]).pluck(:suggested_handle)).to contain_exactly("SC", "SC2") - end - it "increments the suffix until unique" do p1 = create(:project, identifier: "sc-a", name: "Stream Communicator") p2 = create(:project, identifier: "sc-b", name: "Stream Channel") @@ -126,7 +120,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") handles = described_class.call([p1, p2]).pluck(:suggested_handle) - expect(handles.all? { |h| h.length <= described_class::HANDLE_MAX_LENGTH }).to be true + expect(handles.all? { it.length <= described_class::HANDLE_MAX_LENGTH }).to be true expect(handles.uniq.size).to eq(2) end @@ -135,7 +129,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator project = create(:project, identifier: "sc-app", name: "Stream Communicator") result = described_class.call([project], in_use_handles: Set["SC"]) expect(result.first[:suggested_handle]).not_to eq("SC") - expect(result.first[:suggested_handle]).to match(/\ASC\d+\z/) + expect(result.first[:suggested_handle]).to match(/\ASC\d+\z/) # e.g. "SC2" end end From a5ea92db0450ff17060884f899e9f22e2c29f4ab Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 6 Mar 2026 09:19:22 +0300 Subject: [PATCH 028/435] Unify identifier settings form into single state-machine component Replace the two-component approach (IdentifierSettingsFormComponent + IdentifierChangeInProgressComponent) with a single component cycling through three lifecycle states: :edit, :change_in_progress, :completed. --- ...fier_change_in_progress_component.html.erb | 39 ------ .../identifier_radio_form_component.html.erb | 46 ------- ...dentifier_settings_form_component.html.erb | 129 ++++++++++-------- .../identifier_settings_form_component.rb | 48 ++++++- .../work_packages_identifier_controller.rb | 32 +++++ .../work_packages/identifier_autofix.rb} | 20 +-- .../work_packages_identifier/show.html.erb | 2 +- .../identifier_autofix/apply_handles_job.rb} | 11 +- config/locales/en.yml | 3 +- config/routes.rb | 4 +- 10 files changed, 164 insertions(+), 170 deletions(-) delete mode 100644 app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb delete mode 100644 app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb rename app/{components/work_packages/admin/settings/identifier_radio_form_component.rb => services/work_packages/identifier_autofix.rb} (76%) rename app/{components/work_packages/admin/settings/identifier_change_in_progress_component.rb => workers/work_packages/identifier_autofix/apply_handles_job.rb} (86%) diff --git a/app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb b/app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb deleted file mode 100644 index 1c2d3777855..00000000000 --- a/app/components/work_packages/admin/settings/identifier_change_in_progress_component.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -<%# - -- copyright - OpenProject is an open source project management software. - Copyright (C) the OpenProject GmbH - - This program is free software; you can redistribute it and/or - modify it under the terms of the GNU General Public License version 3. - - OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: - Copyright (C) 2006-2013 Jean-Philippe Lang - Copyright (C) 2010-2013 the ChiliProject Team - - This program is free software; you can redistribute it and/or - modify it under the terms of the GNU General Public License - as published by the Free Software Foundation; either version 2 - of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - - See COPYRIGHT and LICENSE files for more details. - - ++# -%> - -<%= render(Primer::Alpha::Banner.new(scheme: :info, my: 3)) do %> - <%= render(Primer::Beta::Spinner.new(size: :small, mr: 2)) %> - <%= I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message") %> -<% end %> - -
- <%= render(WorkPackages::Admin::Settings::IdentifierRadioFormComponent.new) %> -
diff --git a/app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb deleted file mode 100644 index e03389c1e1c..00000000000 --- a/app/components/work_packages/admin/settings/identifier_radio_form_component.html.erb +++ /dev/null @@ -1,46 +0,0 @@ -<%# - -- copyright - OpenProject is an open source project management software. - Copyright (C) the OpenProject GmbH - - This program is free software; you can redistribute it and/or - modify it under the terms of the GNU General Public License version 3. - - OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: - Copyright (C) 2006-2013 Jean-Philippe Lang - Copyright (C) 2010-2013 the ChiliProject Team - - This program is free software; you can redistribute it and/or - modify it under the terms of the GNU General Public License - as published by the Free Software Foundation; either version 2 - of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - - See COPYRIGHT and LICENSE files for more details. - - ++# -%> - -<%= - settings_primer_form_with( - scope: :settings, action: :update, method: :patch, - html: form_html - ) do |f| - render_inline_settings_form(f) do |form| - form.radio_button_group( - name: :work_packages_identifier, - label: I18n.t("settings.work_packages.work_package_identifier"), - required: true, - **radio_button_options - ) - end - end -%> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index 293a10a6cd3..a825a57399b 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -29,64 +29,81 @@ ++# %> -<%= tag.div( - data: { - controller: "admin--work-packages-identifier", - admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects? - } - ) do %> - <%= - render( - WorkPackages::Admin::Settings::IdentifierRadioFormComponent.new( - form_html: { id: "wp-identifier-settings-form" }, - radio_button_options: { - button_options: { - data: { action: "change->admin--work-packages-identifier#handleChange" } - } - } - ) - ) - %> +<%= component_wrapper(**wrapper_data_attrs) do %> + <%= tag.div(**stimulus_div_data_attrs) do %> - <%= tag.div( - hidden: !show_autofix_section?, - data: { admin__work_packages_identifier_target: "autofixSection" } - ) do %> <%= - render( - WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent.new( - projects_data:, - total_count: - ) - ) + settings_primer_form_with( + scope: :settings, action: :update, method: :patch, + html: change_in_progress? ? {} : { id: "wp-identifier-settings-form" } + ) do |f| + render_inline_settings_form(f) do |form| + form.radio_button_group( + name: :work_packages_identifier, + label: I18n.t("settings.work_packages.work_package_identifier"), + required: true, + **radio_button_options + ) + + form.html_content do + if change_in_progress? + render(Primer::Beta::Text.new(my: 3)) do + render(Primer::Beta::Spinner.new(size: :small, mr: 2)).to_s + + I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message") + end + elsif completed? + render(Primer::Alpha::Banner.new(scheme: :success, dismiss_scheme: :remove, mb: 3)) do + I18n.t("admin.settings.work_packages_identifier.success_banner") + end + end + end + end + end %> + + <% unless change_in_progress? %> + <%= tag.div( + hidden: !show_autofix_section?, + data: { admin__work_packages_identifier_target: "autofixSection" } + ) do %> + <%= + render( + WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent.new( + projects_data:, + total_count: + ) + ) + %> + <% end %> + +
+ <%= + render( + Primer::Beta::Button.new( + scheme: :primary, + type: :submit, + form: "wp-identifier-settings-form", + hidden: show_autofix_section?, + data: { admin__work_packages_identifier_target: "saveButton" } + ) + ) { t("button_save") } + %> + <%= + render( + Primer::Beta::Button.new( + scheme: :primary, + type: :button, + hidden: !show_autofix_section?, + data: { + admin__work_packages_identifier_target: "autofixButton", + action: "click->admin--work-packages-identifier#openConfirmDialog" + } + ) + ) { t("admin.settings.work_packages_identifier.button_autofix") } + %> +
+ <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> + <% end %> + <% end %> - -
- <%= - render( - Primer::Beta::Button.new( - scheme: :primary, - type: :submit, - form: "wp-identifier-settings-form", - hidden: show_autofix_section?, - data: { admin__work_packages_identifier_target: "saveButton" } - ) - ) { t("button_save") } - %> - <%= - render( - Primer::Beta::Button.new( - scheme: :primary, - type: :button, - hidden: !show_autofix_section?, - data: { - admin__work_packages_identifier_target: "autofixButton", - action: "click->admin--work-packages-identifier#openConfirmDialog" - } - ) - ) { t("admin.settings.work_packages_identifier.button_autofix") } - %> -
- <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> <% end %> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index 0e6297ca6cf..90d1f0525f7 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -34,11 +34,17 @@ module WorkPackages module Settings class IdentifierSettingsFormComponent < ApplicationComponent include OpPrimer::FormHelpers + include OpTurbo::Streamable - attr_reader :projects_data, :total_count + STATES = %i[edit change_in_progress completed].freeze - def initialize - super + attr_reader :projects_data, :total_count, :state + + def initialize(state: :edit) + raise ArgumentError, "Unknown state: #{state}" unless STATES.include?(state) + + super() + @state = state result = WorkPackages::IdentifierAutofix::PreviewQuery.new.call @projects_data = result.projects_data @total_count = result.total_count @@ -51,7 +57,41 @@ module WorkPackages private def show_autofix_section? - Setting::WorkPackageIdentifier.alphanumeric? && has_problematic_projects? + state == :edit && Setting::WorkPackageIdentifier.alphanumeric? && has_problematic_projects? + end + + def change_in_progress? = state == :change_in_progress + def completed? = state == :completed + + def wrapper_data_attrs + return {} unless change_in_progress? + + { + data: { + controller: "poll-for-changes", + poll_for_changes_url_value: helpers.status_admin_settings_work_packages_identifier_path, + poll_for_changes_interval_value: 5000 + } + } + end + + def stimulus_div_data_attrs + return {} if change_in_progress? + + { + data: { + controller: "admin--work-packages-identifier", + admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects? + } + } + end + + def radio_button_options + if change_in_progress? + { button_options: { disabled: true } } + else + { button_options: { data: { action: "change->admin--work-packages-identifier#handleChange" } } } + end end end end diff --git a/app/controllers/admin/settings/work_packages_identifier_controller.rb b/app/controllers/admin/settings/work_packages_identifier_controller.rb index 537fad2b6ea..b66822ae0d2 100644 --- a/app/controllers/admin/settings/work_packages_identifier_controller.rb +++ b/app/controllers/admin/settings/work_packages_identifier_controller.rb @@ -30,12 +30,44 @@ module Admin::Settings class WorkPackagesIdentifierController < ::Admin::SettingsController + include OpTurbo::ComponentStream + before_action :check_feature_flag current_menu_item :show do :work_packages_identifier end + def show + @form_state = WorkPackages::IdentifierAutofix.job_in_progress? ? :change_in_progress : :edit + end + + def update + return unless params[:settings] + + if ActiveRecord::Type::Boolean.new.cast(params[:confirm_dangerous_action]) + call = update_service.new(user: current_user).call(settings_params) + call.on_success do + WorkPackages::IdentifierAutofix::ApplyHandlesJob.perform_later + redirect_to action: "show" + end + call.on_failure { failure_callback(call) } + else + super + end + end + + def status + if WorkPackages::IdentifierAutofix.job_in_progress? + head :no_content + else + replace_via_turbo_stream( + component: WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new(state: :completed) + ) + respond_with_turbo_streams + end + end + private def check_feature_flag diff --git a/app/components/work_packages/admin/settings/identifier_radio_form_component.rb b/app/services/work_packages/identifier_autofix.rb similarity index 76% rename from app/components/work_packages/admin/settings/identifier_radio_form_component.rb rename to app/services/work_packages/identifier_autofix.rb index 5ed20e344a3..b1917080f79 100644 --- a/app/components/work_packages/admin/settings/identifier_radio_form_component.rb +++ b/app/services/work_packages/identifier_autofix.rb @@ -29,21 +29,11 @@ #++ module WorkPackages - module Admin - module Settings - class IdentifierRadioFormComponent < ApplicationComponent - include OpPrimer::FormHelpers - - def initialize(form_html: {}, radio_button_options: {}) - super() - @form_html = form_html - @radio_button_options = radio_button_options - end - - private - - attr_reader :form_html, :radio_button_options - end + module IdentifierAutofix + def self.job_in_progress? + GoodJob::Job + .where(job_class: WorkPackages::IdentifierAutofix::ApplyHandlesJob.name) + .exists?(finished_at: nil) end end end diff --git a/app/views/admin/settings/work_packages_identifier/show.html.erb b/app/views/admin/settings/work_packages_identifier/show.html.erb index d0ae4254bd6..99633196920 100644 --- a/app/views/admin/settings/work_packages_identifier/show.html.erb +++ b/app/views/admin/settings/work_packages_identifier/show.html.erb @@ -45,4 +45,4 @@ end %> -<%= render(WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new) %> +<%= render(WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new(state: @form_state)) %> diff --git a/app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb b/app/workers/work_packages/identifier_autofix/apply_handles_job.rb similarity index 86% rename from app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb rename to app/workers/work_packages/identifier_autofix/apply_handles_job.rb index 441a1b0128a..9ac0bdbcd43 100644 --- a/app/components/work_packages/admin/settings/identifier_change_in_progress_component.rb +++ b/app/workers/work_packages/identifier_autofix/apply_handles_job.rb @@ -28,12 +28,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackages - module Admin - module Settings - class IdentifierChangeInProgressComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - end - end +class WorkPackages::IdentifierAutofix::ApplyHandlesJob < ApplicationJob + def perform + # FIXME: replace with actual project handle migration + sleep 5 end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 5ec556c1742..65e300f7efb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -239,8 +239,9 @@ en: This change will take some time to complete. confirm_button: Change identifiers checkbox_label: I understand that this will permanently change all work package IDs + success_banner: Successfully updated work package identifier format. in_progress: - banner_message: Project identifiers are currently being updated... + banner_message: Project identifiers are currently being updated to project-based alphanumerical identifiers. This may take some time. workflows: tabs: diff --git a/config/routes.rb b/config/routes.rb index 2b0d6bfe89f..706c1e2bd2c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -660,7 +660,9 @@ Rails.application.routes.draw do # It is important to have this named something else than "work_packages". # Otherwise the angular ui-router will also recognize that as a WorkPackage page and apply according classes. resource :work_packages_general, controller: "/admin/settings/work_packages_general", only: %i[show update] - resource :work_packages_identifier, controller: "/admin/settings/work_packages_identifier", only: %i[show update] + resource :work_packages_identifier, controller: "/admin/settings/work_packages_identifier", only: %i[show update] do + get :status, on: :member + end resources :work_package_priorities, except: [:show] do member do put :move From 8cb25833deaec5ab3db282c314ea0a3309ec3a44 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 19 Feb 2026 17:05:25 +0100 Subject: [PATCH 029/435] Add working hours and non working days for users --- app/models/user.rb | 6 + app/models/user_non_working_day.rb | 47 +++++ app/models/user_working_hours.rb | 72 +++++++ config/initializers/permissions.rb | 8 + config/locales/en.yml | 19 ++ .../20260219151850_add_user_working_hours.rb | 21 ++ ...0260219152519_add_user_non_working_days.rb | 15 ++ .../factories/user_non_working_day_factory.rb | 36 ++++ spec/factories/user_working_hours_factory.rb | 44 ++++ spec/models/user_non_working_day_spec.rb | 116 ++++++++++ spec/models/user_working_hours_spec.rb | 199 ++++++++++++++++++ 11 files changed, 583 insertions(+) create mode 100644 app/models/user_non_working_day.rb create mode 100644 app/models/user_working_hours.rb create mode 100644 db/migrate/20260219151850_add_user_working_hours.rb create mode 100644 db/migrate/20260219152519_add_user_non_working_days.rb create mode 100644 spec/factories/user_non_working_day_factory.rb create mode 100644 spec/factories/user_working_hours_factory.rb create mode 100644 spec/models/user_non_working_day_spec.rb create mode 100644 spec/models/user_working_hours_spec.rb diff --git a/app/models/user.rb b/app/models/user.rb index 6f2f75a890a..ffdcbe1c02b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -60,6 +60,12 @@ class User < Principal has_one :rss_token, class_name: "::Token::RSS", dependent: :destroy has_many :api_tokens, class_name: "::Token::API", dependent: :destroy has_many :oauth_client_tokens, dependent: :destroy + has_many :working_hours, class_name: "UserWorkingHours", + dependent: :destroy, + inverse_of: :user + has_many :non_working_days, class_name: "UserNonWorkingDay", + dependent: :destroy, + inverse_of: :user # The user might have one invitation token has_one :invitation_token, class_name: "::Token::Invitation", dependent: :destroy diff --git a/app/models/user_non_working_day.rb b/app/models/user_non_working_day.rb new file mode 100644 index 00000000000..1ec9def46af --- /dev/null +++ b/app/models/user_non_working_day.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserNonWorkingDay < ApplicationRecord + belongs_to :user, inverse_of: :non_working_days + + validates :date, presence: true, uniqueness: { scope: :user_id } + + scope :for_year, ->(year) { where(date: Date.new(year, 1, 1)..Date.new(year, 12, 31)) } + + scope :for_user, ->(user) { where(user:) } + + scope :visible, ->(user = User.current) do + if user.allowed_globally?(:manage_working_times) + all + else + where(user:) + end + end +end diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb new file mode 100644 index 00000000000..8b30bacce20 --- /dev/null +++ b/app/models/user_working_hours.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours < ApplicationRecord + belongs_to :user, inverse_of: :working_hours + + validates :valid_from, presence: true + validates :monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 24 * 60 } + validates :availability_factor, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + + scope :for_user, ->(user) { where(user:) } + + scope :past, -> { where(valid_from: ...Date.current).order(valid_from: :desc) } + scope :upcoming, -> { where(valid_from: Date.current..).order(valid_from: :asc) } + + def self.valid_for_date(date) + where(valid_from: ..date).order(valid_from: :desc).first + end + + def self.current + valid_for_date(Date.current) + end + + scope :visible, ->(user = User.current) do + if user.allowed_globally?(:manage_working_times) + all + else + where(user:) + end + end + + %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + define_method("#{day}_hours") do + public_send(day) / 60.0 + end + + define_method("#{day}_hours=") do |hours| + public_send("#{day}=", (hours.to_f * 60).round) + end + end +end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 777e2247c47..75233ee0080 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -286,6 +286,14 @@ Rails.application.reloader.to_prepare do {}, permissible_on: :project_query, require: :loggedin + + map.permission :manage_own_working_times, + {}, + permissible_on: :global + + map.permission :manage_working_times, + {}, + permissible_on: :global end map.project_module :work_package_tracking, order: 90 do |wpt| diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e1a60979b7..537511d2594 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1748,6 +1748,25 @@ en: users/invitation/form_model: principal_type: "Invitation type" id_or_email: "Name or email address" + user_non_working_days: + date: "Date" + user_working_hours: + valid_from: "Valid from" + monday: "Monday" + monday_hours: "Monday hours" + tuesday: "Tuesday" + tuesday_hours: "Tuesday hours" + wednesday: "Wednesday" + wednesday_hours: "Wednesday hours" + thursday: "Thursday" + thursday_hours: "Thursday hours" + friday: "Friday" + friday_hours: "Friday hours" + saturday: "Saturday" + saturday_hours: "Saturday hours" + sunday: "Sunday" + sunday_hours: "Sunday hours" + availability_factor: "Availability factor" version: effective_date: "Finish date" sharing: "Sharing" diff --git a/db/migrate/20260219151850_add_user_working_hours.rb b/db/migrate/20260219151850_add_user_working_hours.rb new file mode 100644 index 00000000000..79cbd3e4b6b --- /dev/null +++ b/db/migrate/20260219151850_add_user_working_hours.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddUserWorkingHours < ActiveRecord::Migration[8.1] + def change + create_table :user_working_hours do |t| + t.references :user, null: false, foreign_key: true + + t.date :valid_from, null: false, index: true + t.integer :monday, null: false + t.integer :tuesday, null: false + t.integer :wednesday, null: false + t.integer :thursday, null: false + t.integer :friday, null: false + t.integer :saturday, null: false + t.integer :sunday, null: false + t.integer :availability_factor, null: false, default: 100 + + t.timestamps + end + end +end diff --git a/db/migrate/20260219152519_add_user_non_working_days.rb b/db/migrate/20260219152519_add_user_non_working_days.rb new file mode 100644 index 00000000000..0822a66eedb --- /dev/null +++ b/db/migrate/20260219152519_add_user_non_working_days.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddUserNonWorkingDays < ActiveRecord::Migration[8.1] + def change + create_table :user_non_working_days do |t| + t.references :user, null: false, foreign_key: true + + t.date :date, null: false, index: true + + t.timestamps + + t.index %i[user_id date], unique: true + end + end +end diff --git a/spec/factories/user_non_working_day_factory.rb b/spec/factories/user_non_working_day_factory.rb new file mode 100644 index 00000000000..45ba851eb87 --- /dev/null +++ b/spec/factories/user_non_working_day_factory.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :user_non_working_day do + user + sequence(:date) { |n| Date.current + n.days } + end +end diff --git a/spec/factories/user_working_hours_factory.rb b/spec/factories/user_working_hours_factory.rb new file mode 100644 index 00000000000..301d73b81f5 --- /dev/null +++ b/spec/factories/user_working_hours_factory.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :user_working_hours do + user + sequence(:valid_from) { |n| Date.current + n.days } + monday { 480 } + tuesday { 480 } + wednesday { 480 } + thursday { 480 } + friday { 480 } + saturday { 0 } + sunday { 0 } + availability_factor { 100 } + end +end diff --git a/spec/models/user_non_working_day_spec.rb b/spec/models/user_non_working_day_spec.rb new file mode 100644 index 00000000000..7ba88e448ed --- /dev/null +++ b/spec/models/user_non_working_day_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe UserNonWorkingDay do + subject(:non_working_day) { build(:user_non_working_day) } + + describe "validations" do + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:date) } + + it "validates uniqueness of date scoped to user" do + existing = create(:user_non_working_day) + duplicate = build(:user_non_working_day, user: existing.user, date: existing.date) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:date]).to be_present + end + + it "allows the same date for different users" do + existing = create(:user_non_working_day) + other_user = create(:user) + other = build(:user_non_working_day, user: other_user, date: existing.date) + + expect(other).to be_valid + end + end + + describe ".for_year" do + let(:user) { create(:user) } + let!(:day_in_year) { create(:user_non_working_day, user:, date: Date.new(2025, 6, 15)) } + let!(:day_at_start) { create(:user_non_working_day, user:, date: Date.new(2025, 1, 1)) } + let!(:day_at_end) { create(:user_non_working_day, user:, date: Date.new(2025, 12, 31)) } + let!(:day_outside_year) { create(:user_non_working_day, user:, date: Date.new(2024, 12, 31)) } + + it "returns records within the given year" do + expect(described_class.for_user(user).for_year(2025)).to contain_exactly(day_in_year, day_at_start, day_at_end) + end + + it "excludes records outside the given year" do + expect(described_class.for_user(user).for_year(2025)).not_to include(day_outside_year) + end + end + + describe ".for_user" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_day) { create(:user_non_working_day, user:) } + let!(:other_day) { create(:user_non_working_day, user: other_user) } + + it "returns only records for the given user" do + expect(described_class.for_user(user)).to contain_exactly(user_day) + end + + it "excludes records for other users" do + expect(described_class.for_user(user)).not_to include(other_day) + end + end + + describe ".visible" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_day) { create(:user_non_working_day, user:) } + let!(:other_day) { create(:user_non_working_day, user: other_user) } + + context "when the viewer has :manage_working_times permission" do + let(:viewer) { create(:user, global_permissions: [:manage_working_times]) } + + it "returns all records" do + expect(described_class.visible(viewer)).to contain_exactly(user_day, other_day) + end + end + + context "when the viewer has no special permissions" do + let(:viewer) { create(:user) } + let!(:viewer_day) { create(:user_non_working_day, user: viewer) } + + it "returns only their own records" do + expect(described_class.visible(viewer)).to contain_exactly(viewer_day) + end + + it "excludes other users' records" do + expect(described_class.visible(viewer)).not_to include(user_day, other_day) + end + end + end +end diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb new file mode 100644 index 00000000000..bd51ab0358e --- /dev/null +++ b/spec/models/user_working_hours_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe UserWorkingHours do + subject(:working_hours) { build(:user_working_hours) } + + describe "validations" do + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:valid_from) } + + %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + it { is_expected.to validate_presence_of(day) } + + it do + expect(subject).to validate_numericality_of(day).only_integer + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(24 * 60) + end + end + + it { is_expected.to validate_presence_of(:availability_factor) } + + it do + expect(subject).to validate_numericality_of(:availability_factor).only_integer + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(100) + end + end + + describe "hours accessors" do + subject(:working_hours) { build(:user_working_hours, monday: 480, tuesday: 90, wednesday: 0) } + + %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + describe "##{day}_hours" do + it "returns the minutes value converted to hours" do + working_hours.public_send("#{day}=", 150) + expect(working_hours.public_send("#{day}_hours")).to eq(2.5) + end + end + + describe "##{day}_hours=" do + it "stores the hours value converted to minutes" do + working_hours.public_send("#{day}_hours=", 7.5) + expect(working_hours.public_send(day)).to eq(450) + end + + it "rounds fractional minutes" do + working_hours.public_send("#{day}_hours=", 1.0 / 3) + expect(working_hours.public_send(day)).to eq(20) + end + end + end + + it "returns 8.0 hours for a full work day of 480 minutes" do + expect(working_hours.monday_hours).to eq(8.0) + end + + it "returns 1.5 hours for 90 minutes" do + expect(working_hours.tuesday_hours).to eq(1.5) + end + + it "returns 0.0 for a non-working day" do + expect(working_hours.wednesday_hours).to eq(0.0) + end + end + + describe ".valid_for_date" do + let(:user) { create(:user) } + let!(:old_hours) { create(:user_working_hours, user:, valid_from: 30.days.ago) } + let!(:recent_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) } + let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) } + + it "returns the most recent record valid on the given date" do + expect(described_class.for_user(user).valid_for_date(Date.current)).to eq(recent_hours) + end + + it "returns the correct record for a past date" do + expect(described_class.for_user(user).valid_for_date(20.days.ago.to_date)).to eq(old_hours) + end + + it "returns nil when no record is valid for the given date" do + expect(described_class.for_user(user).valid_for_date(31.days.ago.to_date)).to be_nil + end + + it "does not return future records" do + expect(described_class.for_user(user).valid_for_date(Date.current)).not_to eq(future_hours) + end + end + + describe ".current" do + let(:user) { create(:user) } + let!(:past_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) } + let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) } + + it "returns the currently valid record" do + expect(described_class.for_user(user).current).to eq(past_hours) + end + + it "does not return future records" do + expect(described_class.for_user(user).current).not_to eq(future_hours) + end + end + + describe ".past" do + let(:user) { create(:user) } + let!(:older_hours) { create(:user_working_hours, user:, valid_from: 20.days.ago) } + let!(:recent_past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) } + let!(:future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) } + + it "returns records with valid_from before today" do + expect(described_class.for_user(user).past).to contain_exactly(older_hours, recent_past_hours) + end + + it "orders results descending by valid_from" do + expect(described_class.for_user(user).past).to eq([recent_past_hours, older_hours]) + end + + it "excludes future records" do + expect(described_class.for_user(user).past).not_to include(future_hours) + end + end + + describe ".upcoming" do + let(:user) { create(:user) } + let!(:past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) } + let!(:near_future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) } + let!(:far_future_hours) { create(:user_working_hours, user:, valid_from: 20.days.from_now) } + + it "returns records with valid_from from today onwards" do + expect(described_class.for_user(user).upcoming).to contain_exactly(near_future_hours, far_future_hours) + end + + it "orders results ascending by valid_from" do + expect(described_class.for_user(user).upcoming).to eq([near_future_hours, far_future_hours]) + end + + it "excludes past records" do + expect(described_class.for_user(user).upcoming).not_to include(past_hours) + end + end + + describe ".visible" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_hours) { create(:user_working_hours, user:) } + let!(:other_hours) { create(:user_working_hours, user: other_user) } + + context "when the viewer has :manage_working_times permission" do + let(:viewer) { create(:user, global_permissions: [:manage_working_times]) } + + it "returns all records" do + expect(described_class.visible(viewer)).to contain_exactly(user_hours, other_hours) + end + end + + context "when the viewer has no special permissions" do + let(:viewer) { create(:user) } + let!(:viewer_hours) { create(:user_working_hours, user: viewer) } + + it "returns only their own records" do + expect(described_class.visible(viewer)).to contain_exactly(viewer_hours) + end + + it "excludes other users' records" do + expect(described_class.visible(viewer)).not_to include(user_hours, other_hours) + end + end + end +end From 64ea8776a9475aae6ab4eeb68ac2db3e9803aaba Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 24 Feb 2026 13:11:44 +0100 Subject: [PATCH 030/435] Add translations for working times permissions --- config/locales/en.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index 537511d2594..f291ff409f0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4510,6 +4510,12 @@ en: permission_manage_versions: "Manage versions" permission_manage_wiki: "Manage wiki" permission_manage_wiki_menu: "Manage wiki menu" + permission_manage_own_working_times: "Manage own working times" + permission_manage_own_working_times_explanation: > + Allows users to manage their own working times, and personal non-working days. + permission_manage_working_times: "Manage working times for all users" + permission_manage_working_times_explanation: > + Allows users to manage working times for all users, including personal non-working days. permission_move_work_packages: "Move work packages" permission_protect_wiki_pages: "Protect wiki pages" permission_rename_wiki_pages: "Rename wiki pages" From 3f3193e70055f0940c8e612bf83968c3fa0f716e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 24 Feb 2026 17:16:44 +0100 Subject: [PATCH 031/435] Add helpers to calculate weekly working hours and effective working hours --- app/models/user_working_hours.rb | 10 ++++++++- spec/models/user_working_hours_spec.rb | 29 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb index 8b30bacce20..fccad1a9747 100644 --- a/app/models/user_working_hours.rb +++ b/app/models/user_working_hours.rb @@ -62,11 +62,19 @@ class UserWorkingHours < ApplicationRecord %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| define_method("#{day}_hours") do - public_send(day) / 60.0 + (public_send(day) / 60.0).round(2) end define_method("#{day}_hours=") do |hours| public_send("#{day}=", (hours.to_f * 60).round) end end + + def weekly_working_hours + %i[monday tuesday wednesday thursday friday saturday sunday].sum { |day| public_send("#{day}_hours") } + end + + def effective_weekly_working_hours + ((weekly_working_hours * availability_factor) / 100.0).round(2) + end end diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb index bd51ab0358e..96b2e318cec 100644 --- a/spec/models/user_working_hours_spec.rb +++ b/spec/models/user_working_hours_spec.rb @@ -94,6 +94,35 @@ RSpec.describe UserWorkingHours do end end + describe "#weekly_working_hours" do + it "sums the daily working hours for the week" do + working_hours.monday = 480 + working_hours.tuesday = 240 + working_hours.wednesday = 0 + working_hours.thursday = 120 + working_hours.friday = 480 + working_hours.saturday = 0 + working_hours.sunday = 0 + + expect(working_hours.weekly_working_hours).to eq(8.0 + 4.0 + 0.0 + 2.0 + 8.0 + 0.0 + 0.0) + end + end + + describe "#effective_weekly_working_hours" do + it "calculates the effective weekly working hours based on the availability factor" do + working_hours.monday = 480 + working_hours.tuesday = 240 + working_hours.wednesday = 0 + working_hours.thursday = 120 + working_hours.friday = 480 + working_hours.saturday = 0 + working_hours.sunday = 0 + + working_hours.availability_factor = 50 + expect(working_hours.effective_weekly_working_hours).to eq(((8.0 + 4.0 + 0.0 + 2.0 + 8.0) / 2.0).round(2)) + end + end + describe ".valid_for_date" do let(:user) { create(:user) } let!(:old_hours) { create(:user_working_hours, user:, valid_from: 30.days.ago) } From 51558b9979b63af2c77db1fcadfe02095a8e5ef4 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 24 Feb 2026 17:46:03 +0100 Subject: [PATCH 032/435] Add services for UserNonWorkingDay and UserWorkingHours --- .../user_non_working_days/base_contract.rb | 53 ++++++++ .../user_non_working_days/create_contract.rb | 47 +++++++ .../user_non_working_days/delete_contract.rb | 38 ++++++ .../user_working_hours/base_contract.rb | 65 ++++++++++ .../user_working_hours/create_contract.rb | 32 +++++ .../user_working_hours/delete_contract.rb | 36 ++++++ .../user_working_hours/update_contract.rb | 47 +++++++ .../user_non_working_days/create_service.rb | 34 +++++ .../user_non_working_days/delete_service.rb | 34 +++++ .../set_attributes_service.rb | 34 +++++ .../user_working_hours/create_service.rb | 39 ++++++ .../user_working_hours/delete_service.rb | 32 +++++ .../set_attributes_service.rb | 32 +++++ .../user_working_hours/update_service.rb | 32 +++++ config/locales/en.yml | 2 + .../create_contract_spec.rb | 120 ++++++++++++++++++ .../delete_contract_spec.rb | 85 +++++++++++++ .../user_working_hours/base_contract_spec.rb | 85 +++++++++++++ .../create_contract_spec.rb | 73 +++++++++++ .../delete_contract_spec.rb | 85 +++++++++++++ .../update_contract_spec.rb | 113 +++++++++++++++++ .../create_service_spec.rb | 103 +++++++++++++++ .../delete_service_spec.rb | 79 ++++++++++++ .../user_working_hours/create_service_spec.rb | 99 +++++++++++++++ .../user_working_hours/delete_service_spec.rb | 79 ++++++++++++ .../user_working_hours/update_service_spec.rb | 89 +++++++++++++ 26 files changed, 1567 insertions(+) create mode 100644 app/contracts/user_non_working_days/base_contract.rb create mode 100644 app/contracts/user_non_working_days/create_contract.rb create mode 100644 app/contracts/user_non_working_days/delete_contract.rb create mode 100644 app/contracts/user_working_hours/base_contract.rb create mode 100644 app/contracts/user_working_hours/create_contract.rb create mode 100644 app/contracts/user_working_hours/delete_contract.rb create mode 100644 app/contracts/user_working_hours/update_contract.rb create mode 100644 app/services/user_non_working_days/create_service.rb create mode 100644 app/services/user_non_working_days/delete_service.rb create mode 100644 app/services/user_non_working_days/set_attributes_service.rb create mode 100644 app/services/user_working_hours/create_service.rb create mode 100644 app/services/user_working_hours/delete_service.rb create mode 100644 app/services/user_working_hours/set_attributes_service.rb create mode 100644 app/services/user_working_hours/update_service.rb create mode 100644 spec/contracts/user_non_working_days/create_contract_spec.rb create mode 100644 spec/contracts/user_non_working_days/delete_contract_spec.rb create mode 100644 spec/contracts/user_working_hours/base_contract_spec.rb create mode 100644 spec/contracts/user_working_hours/create_contract_spec.rb create mode 100644 spec/contracts/user_working_hours/delete_contract_spec.rb create mode 100644 spec/contracts/user_working_hours/update_contract_spec.rb create mode 100644 spec/services/user_non_working_days/create_service_spec.rb create mode 100644 spec/services/user_non_working_days/delete_service_spec.rb create mode 100644 spec/services/user_working_hours/create_service_spec.rb create mode 100644 spec/services/user_working_hours/delete_service_spec.rb create mode 100644 spec/services/user_working_hours/update_service_spec.rb diff --git a/app/contracts/user_non_working_days/base_contract.rb b/app/contracts/user_non_working_days/base_contract.rb new file mode 100644 index 00000000000..d5fadac4c5a --- /dev/null +++ b/app/contracts/user_non_working_days/base_contract.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingDays + class BaseContract < ::ModelContract + attribute :user_id + attribute :date + + validate :validate_manage_permission + + def self.model = ::UserNonWorkingDay + + private + + def validate_manage_permission + unless can_manage_working_times? + errors.add :base, :error_unauthorized + end + end + + def can_manage_working_times? + user.allowed_globally?(:manage_working_times) || + (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) + end + end +end diff --git a/app/contracts/user_non_working_days/create_contract.rb b/app/contracts/user_non_working_days/create_contract.rb new file mode 100644 index 00000000000..74ea7bba460 --- /dev/null +++ b/app/contracts/user_non_working_days/create_contract.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingDays + class CreateContract < BaseContract + validate :validate_no_system_wide_conflict + + private + + # A user-specific non-working day cannot be added when a system-wide + # non-working day already exists for the same date. + def validate_no_system_wide_conflict + return if model.date.blank? + + if NonWorkingDay.exists?(date: model.date) + errors.add :date, :system_wide_non_working_day_exists + end + end + end +end diff --git a/app/contracts/user_non_working_days/delete_contract.rb b/app/contracts/user_non_working_days/delete_contract.rb new file mode 100644 index 00000000000..53643596e06 --- /dev/null +++ b/app/contracts/user_non_working_days/delete_contract.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingDays + class DeleteContract < ::DeleteContract + delete_permission -> { + user.allowed_globally?(:manage_working_times) || + (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) + } + end +end diff --git a/app/contracts/user_working_hours/base_contract.rb b/app/contracts/user_working_hours/base_contract.rb new file mode 100644 index 00000000000..cf69cc683e3 --- /dev/null +++ b/app/contracts/user_working_hours/base_contract.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::BaseContract < ModelContract + DAYS = %i[monday tuesday wednesday thursday friday saturday sunday].freeze + + attribute :user_id + attribute :valid_from + DAYS.each { |day| attribute :"#{day}_hours" } + attribute :availability_factor + + validate :validate_manage_permission + + def self.model = ::UserWorkingHours + + private + + def validate_manage_permission + unless can_manage_working_hours? + errors.add :base, :error_unauthorized + end + end + + def can_manage_working_hours? + user.allowed_globally?(:manage_working_times) || + (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) + end + + # The model stores day values as minutes (e.g. `monday`), but the public + # interface uses hours (e.g. `monday_hours`). When `monday_hours=` is called, + # the underlying `monday` column becomes dirty. Override `changed_by_user` to + # map those raw column names back to their hours equivalents so the writable + # attribute check passes correctly. + def changed_by_user + day_names = DAYS.map(&:to_s) + super.map { |attr| day_names.include?(attr) ? "#{attr}_hours" : attr } + end +end diff --git a/app/contracts/user_working_hours/create_contract.rb b/app/contracts/user_working_hours/create_contract.rb new file mode 100644 index 00000000000..b2c587a418a --- /dev/null +++ b/app/contracts/user_working_hours/create_contract.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::CreateContract < UserWorkingHours::BaseContract +end diff --git a/app/contracts/user_working_hours/delete_contract.rb b/app/contracts/user_working_hours/delete_contract.rb new file mode 100644 index 00000000000..26007e94ffe --- /dev/null +++ b/app/contracts/user_working_hours/delete_contract.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::DeleteContract < DeleteContract + delete_permission -> { + user.allowed_globally?(:manage_working_times) || + (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) + } +end diff --git a/app/contracts/user_working_hours/update_contract.rb b/app/contracts/user_working_hours/update_contract.rb new file mode 100644 index 00000000000..c94e8f8b449 --- /dev/null +++ b/app/contracts/user_working_hours/update_contract.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::UpdateContract < UserWorkingHours::BaseContract + validate :validate_valid_from_in_future + + private + + # Records that are already in effect (valid_from in the past) cannot be edited. + # Use valid_from_was to check the original value before any changes in this request. + # Falls back to the current valid_from for new/unsaved records (e.g., in tests). + def validate_valid_from_in_future + original_valid_from = model.valid_from_was.presence || model.valid_from + return if original_valid_from.nil? + + unless original_valid_from > Date.current + errors.add :base, :not_editable + end + end +end diff --git a/app/services/user_non_working_days/create_service.rb b/app/services/user_non_working_days/create_service.rb new file mode 100644 index 00000000000..f499d172a1a --- /dev/null +++ b/app/services/user_non_working_days/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingDays + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/user_non_working_days/delete_service.rb b/app/services/user_non_working_days/delete_service.rb new file mode 100644 index 00000000000..5c76c078226 --- /dev/null +++ b/app/services/user_non_working_days/delete_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingDays + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/user_non_working_days/set_attributes_service.rb b/app/services/user_non_working_days/set_attributes_service.rb new file mode 100644 index 00000000000..d8f328b0530 --- /dev/null +++ b/app/services/user_non_working_days/set_attributes_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingDays + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/user_working_hours/create_service.rb b/app/services/user_working_hours/create_service.rb new file mode 100644 index 00000000000..938b639638b --- /dev/null +++ b/app/services/user_working_hours/create_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::CreateService < BaseServices::Create + protected + + # UserWorkingHours is already plural, so the default singularize-based + # instance_class lookup (UserWorkingHour) would fail. Override it explicitly. + def instance_class + ::UserWorkingHours + end +end diff --git a/app/services/user_working_hours/delete_service.rb b/app/services/user_working_hours/delete_service.rb new file mode 100644 index 00000000000..5a9b5174f55 --- /dev/null +++ b/app/services/user_working_hours/delete_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::DeleteService < BaseServices::Delete +end diff --git a/app/services/user_working_hours/set_attributes_service.rb b/app/services/user_working_hours/set_attributes_service.rb new file mode 100644 index 00000000000..686ae5d9d65 --- /dev/null +++ b/app/services/user_working_hours/set_attributes_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::SetAttributesService < BaseServices::SetAttributes +end diff --git a/app/services/user_working_hours/update_service.rb b/app/services/user_working_hours/update_service.rb new file mode 100644 index 00000000000..4f4c8103e17 --- /dev/null +++ b/app/services/user_working_hours/update_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserWorkingHours::UpdateService < BaseServices::Update +end diff --git a/config/locales/en.yml b/config/locales/en.yml index f291ff409f0..77958488c3e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1858,7 +1858,9 @@ en: less_than_or_equal_to: "must be less than or equal to %{count}." not_available: "is not available due to a system configuration." not_deletable: "cannot be deleted." + not_editable: "cannot be edited because it is already in effect." not_current_user: "is not the current user." + system_wide_non_working_day_exists: "conflicts with an existing system-wide non-working day for this date." only_one_active_sprint_allowed: "only one active sprint is allowed per project." not_found: "not found." not_a_date: "is not a valid date." diff --git a/spec/contracts/user_non_working_days/create_contract_spec.rb b/spec/contracts/user_non_working_days/create_contract_spec.rb new file mode 100644 index 00000000000..cc99f5b1d4d --- /dev/null +++ b/spec/contracts/user_non_working_days/create_contract_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe UserNonWorkingDays::CreateContract do + include_context "ModelContract shared context" + + let(:target_user) { build_stubbed(:user) } + let(:current_user) { build_stubbed(:user) } + let(:date) { Date.tomorrow } + let(:non_working_day) { build_stubbed(:user_non_working_day, user: target_user, date:) } + let(:contract) { described_class.new(non_working_day, current_user) } + + before do + allow(NonWorkingDay).to receive(:exists?).with(date:).and_return(false) + end + + context "with global manage_working_times permission" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times and owning the record" do + let(:current_user) { target_user } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times but not owning the record" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + context "without any relevant permissions" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + context "when a system-wide non-working day exists for the same date" do + let(:current_user) { build_stubbed(:user) } + + before do + allow(NonWorkingDay).to receive(:exists?).with(date:).and_return(true) + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it_behaves_like "contract is invalid", date: :system_wide_non_working_day_exists + end + + context "when the date is blank" do + let(:current_user) { build_stubbed(:user) } + let(:date) { nil } + + before do + allow(NonWorkingDay).to receive(:exists?).with(date: nil).and_return(false) + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it "does not add a system_wide_non_working_day_exists error" do + contract.validate + expect(contract.errors.symbols_for(:date)).not_to include(:system_wide_non_working_day_exists) + end + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/contracts/user_non_working_days/delete_contract_spec.rb b/spec/contracts/user_non_working_days/delete_contract_spec.rb new file mode 100644 index 00000000000..9b6397e81e4 --- /dev/null +++ b/spec/contracts/user_non_working_days/delete_contract_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe UserNonWorkingDays::DeleteContract do + include_context "ModelContract shared context" + + let(:target_user) { build_stubbed(:user) } + let(:current_user) { build_stubbed(:user) } + let(:non_working_day) { build_stubbed(:user_non_working_day, user: target_user) } + let(:contract) { described_class.new(non_working_day, current_user) } + + context "with global manage_working_times permission" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times and owning the record" do + let(:current_user) { target_user } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times but not owning the record" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract user is unauthorized" + end + + context "without any relevant permissions" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract user is unauthorized" + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/contracts/user_working_hours/base_contract_spec.rb b/spec/contracts/user_working_hours/base_contract_spec.rb new file mode 100644 index 00000000000..971c8044d5a --- /dev/null +++ b/spec/contracts/user_working_hours/base_contract_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe UserWorkingHours::BaseContract do + include_context "ModelContract shared context" + + let(:target_user) { build_stubbed(:user) } + let(:current_user) { build_stubbed(:user) } + let(:working_hours) { build_stubbed(:user_working_hours, user: target_user) } + let(:contract) { described_class.new(working_hours, current_user) } + + context "when the current user has the global manage_working_times permission" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "when the current user has manage_own_working_times and owns the record" do + let(:current_user) { target_user } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "when the current user has manage_own_working_times but does NOT own the record" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + context "when the current user has no relevant permissions" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/contracts/user_working_hours/create_contract_spec.rb b/spec/contracts/user_working_hours/create_contract_spec.rb new file mode 100644 index 00000000000..9baed3780e3 --- /dev/null +++ b/spec/contracts/user_working_hours/create_contract_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe UserWorkingHours::CreateContract do + include_context "ModelContract shared context" + + let(:target_user) { build_stubbed(:user) } + let(:current_user) { build_stubbed(:user) } + let(:working_hours) { build_stubbed(:user_working_hours, user: target_user) } + let(:contract) { described_class.new(working_hours, current_user) } + + context "with global manage_working_times permission" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times and owning the record" do + let(:current_user) { target_user } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "without permissions" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/contracts/user_working_hours/delete_contract_spec.rb b/spec/contracts/user_working_hours/delete_contract_spec.rb new file mode 100644 index 00000000000..8bd5f612600 --- /dev/null +++ b/spec/contracts/user_working_hours/delete_contract_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe UserWorkingHours::DeleteContract do + include_context "ModelContract shared context" + + let(:target_user) { build_stubbed(:user) } + let(:current_user) { build_stubbed(:user) } + let(:working_hours) { build_stubbed(:user_working_hours, user: target_user) } + let(:contract) { described_class.new(working_hours, current_user) } + + context "with global manage_working_times permission" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times and owning the record" do + let(:current_user) { target_user } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times but not owning the record" do + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract user is unauthorized" + end + + context "without any relevant permissions" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract user is unauthorized" + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/contracts/user_working_hours/update_contract_spec.rb b/spec/contracts/user_working_hours/update_contract_spec.rb new file mode 100644 index 00000000000..9dbca71d468 --- /dev/null +++ b/spec/contracts/user_working_hours/update_contract_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe UserWorkingHours::UpdateContract do + include_context "ModelContract shared context" + + let(:target_user) { build_stubbed(:user) } + let(:valid_from) { Date.tomorrow } + let(:working_hours) { build_stubbed(:user_working_hours, user: target_user, valid_from:) } + let(:contract) { described_class.new(working_hours, current_user) } + let(:current_user) { build_stubbed(:user) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_working_times) + end + end + + context "when valid_from is in the future" do + let(:valid_from) { Date.tomorrow } + + it_behaves_like "contract is valid" + end + + context "when valid_from is today" do + let(:valid_from) { Date.current } + + it_behaves_like "contract is invalid", base: :not_editable + end + + context "when valid_from is in the past" do + let(:valid_from) { Date.yesterday } + + it_behaves_like "contract is invalid", base: :not_editable + end + + context "when valid_from was changed from past to future" do + let(:valid_from) { Date.yesterday } + + before do + working_hours.valid_from = Date.tomorrow + end + + it_behaves_like "contract is invalid", base: :not_editable + end + + context "without manage_working_times or manage_own_working_times permission" do + before do + mock_permissions_for(current_user) do |mock| + # no permissions granted + end + end + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + context "with manage_own_working_times and owning the record" do + let(:current_user) { target_user } + let(:valid_from) { Date.tomorrow } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is valid" + end + + context "with manage_own_working_times but not owning the record" do + let(:valid_from) { Date.tomorrow } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_globally(:manage_own_working_times) + end + end + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" +end diff --git a/spec/services/user_non_working_days/create_service_spec.rb b/spec/services/user_non_working_days/create_service_spec.rb new file mode 100644 index 00000000000..2996b513131 --- /dev/null +++ b/spec/services/user_non_working_days/create_service_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_create_service" + +RSpec.describe UserNonWorkingDays::CreateService do + it_behaves_like "BaseServices create service" do + let(:factory) { :user_non_working_day } + end + + subject(:service_call) { described_class.new(user: current_user).call(params) } + + let(:target_user) { create(:user) } + let(:date) { Date.tomorrow } + let(:params) { { user: target_user, date: } } + + context "when the current user has the global manage_working_times permission" do + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + + it "creates the non-working day record successfully" do + expect(service_call).to be_success + expect(service_call.result).to be_a(UserNonWorkingDay) + expect(service_call.result).to be_persisted + expect(service_call.result.user).to eq(target_user) + expect(service_call.result.date).to eq(date) + end + end + + context "when the current user has manage_own_working_times for their own record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let(:params) { { user: current_user, date: } } + + it "creates the non-working day record successfully" do + expect(service_call).to be_success + expect(service_call.result.user).to eq(current_user) + end + end + + context "when the current user has manage_own_working_times but targets another user" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end + + context "when the current user has no relevant permissions" do + let(:current_user) { create(:user) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end + + context "when a system-wide non-working day exists for the same date" do + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + + before { create(:non_working_day, date:) } + + it "is unsuccessful due to the system-wide conflict" do + expect(service_call).to be_failure + expect(service_call.errors[:date]).to include( + I18n.t("activerecord.errors.messages.system_wide_non_working_day_exists") + ) + end + end + + context "when no system-wide non-working day exists for the date" do + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + + it "creates the record" do + expect(service_call).to be_success + end + end +end diff --git a/spec/services/user_non_working_days/delete_service_spec.rb b/spec/services/user_non_working_days/delete_service_spec.rb new file mode 100644 index 00000000000..31edc02291c --- /dev/null +++ b/spec/services/user_non_working_days/delete_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_delete_service" + +RSpec.describe UserNonWorkingDays::DeleteService do + it_behaves_like "BaseServices delete service" do + let(:factory) { :user_non_working_day } + end + + subject(:service_call) { described_class.new(user: current_user, model: non_working_day).call } + + let(:target_user) { create(:user) } + let(:non_working_day) { create(:user_non_working_day, user: target_user) } + + context "when the current user has the global manage_working_times permission" do + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + + it "deletes the record successfully" do + expect(service_call).to be_success + expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + end + end + + context "when the current user has manage_own_working_times and owns the record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let(:non_working_day) { create(:user_non_working_day, user: current_user) } + + it "deletes the record successfully" do + expect(service_call).to be_success + expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + end + end + + context "when the current user has manage_own_working_times but targets another user's record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + it "is unsuccessful" do + expect(service_call).to be_failure + expect(UserNonWorkingDay.find_by(id: non_working_day.id)).not_to be_nil + end + end + + context "when the current user has no relevant permissions" do + let(:current_user) { create(:user) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end +end diff --git a/spec/services/user_working_hours/create_service_spec.rb b/spec/services/user_working_hours/create_service_spec.rb new file mode 100644 index 00000000000..ce15dba540a --- /dev/null +++ b/spec/services/user_working_hours/create_service_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_create_service" + +RSpec.describe UserWorkingHours::CreateService do + it_behaves_like "BaseServices create service" do + let(:factory) { :user_working_hours } + # UserWorkingHours is already plural, so the default singularize-based + # model_class lookup (UserWorkingHour) would fail. Override explicitly. + let(:model_class) { UserWorkingHours } + end + + subject(:service_call) { described_class.new(user: current_user).call(params) } + + let(:target_user) { create(:user) } + let(:params) do + { + user: target_user, + valid_from: Date.tomorrow, + monday_hours: 8, + tuesday_hours: 8, + wednesday_hours: 8, + thursday_hours: 8, + friday_hours: 8, + saturday_hours: 0, + sunday_hours: 0, + availability_factor: 100 + } + end + + context "when the current user has the global manage_working_times permission" do + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + + it "creates the working hours record successfully" do + expect(service_call).to be_success + expect(service_call.result).to be_a(UserWorkingHours) + expect(service_call.result).to be_persisted + expect(service_call.result.user).to eq(target_user) + expect(service_call.result.valid_from).to eq(Date.tomorrow) + expect(service_call.result.monday_hours).to eq(8) + end + end + + context "when the current user has manage_own_working_times for their own record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let(:params) { super().merge(user: current_user) } + + it "creates the working hours record successfully" do + expect(service_call).to be_success + expect(service_call.result.user).to eq(current_user) + end + end + + context "when the current user has manage_own_working_times but targets another user" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + it "is unsuccessful and returns an authorization error" do + expect(service_call).to be_failure + expect(service_call.errors[:base]).to include(I18n.t("activerecord.errors.messages.error_unauthorized")) + end + end + + context "when the current user has no relevant permissions" do + let(:current_user) { create(:user) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end +end diff --git a/spec/services/user_working_hours/delete_service_spec.rb b/spec/services/user_working_hours/delete_service_spec.rb new file mode 100644 index 00000000000..ee18086142f --- /dev/null +++ b/spec/services/user_working_hours/delete_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_delete_service" + +RSpec.describe UserWorkingHours::DeleteService do + it_behaves_like "BaseServices delete service" do + let(:factory) { :user_working_hours } + end + + subject(:service_call) { described_class.new(user: current_user, model: working_hours).call } + + let(:target_user) { create(:user) } + let(:working_hours) { create(:user_working_hours, user: target_user) } + + context "when the current user has the global manage_working_times permission" do + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + + it "deletes the record successfully" do + expect(service_call).to be_success + expect(UserWorkingHours.find_by(id: working_hours.id)).to be_nil + end + end + + context "when the current user has manage_own_working_times and owns the record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let(:working_hours) { create(:user_working_hours, user: current_user) } + + it "deletes the record successfully" do + expect(service_call).to be_success + expect(UserWorkingHours.find_by(id: working_hours.id)).to be_nil + end + end + + context "when the current user has manage_own_working_times but targets another user's record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + it "is unsuccessful" do + expect(service_call).to be_failure + expect(UserWorkingHours.find_by(id: working_hours.id)).not_to be_nil + end + end + + context "when the current user has no relevant permissions" do + let(:current_user) { create(:user) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end +end diff --git a/spec/services/user_working_hours/update_service_spec.rb b/spec/services/user_working_hours/update_service_spec.rb new file mode 100644 index 00000000000..f7b93da5282 --- /dev/null +++ b/spec/services/user_working_hours/update_service_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_update_service" + +RSpec.describe UserWorkingHours::UpdateService do + it_behaves_like "BaseServices update service" do + let(:factory) { :user_working_hours } + end + + subject(:service_call) { described_class.new(user: current_user, model: working_hours).call(params) } + + let(:target_user) { create(:user) } + let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } + let(:params) { { monday_hours: 5 } } + + context "when the record has a future valid_from" do + let(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.tomorrow) } + + it "updates the record successfully" do + expect(service_call).to be_success + expect(service_call.result.monday_hours).to eq(5) + end + end + + context "when the record has a past valid_from (already in effect)" do + let(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.yesterday) } + + it "is unsuccessful because in-effect records cannot be edited" do + expect(service_call).to be_failure + expect(service_call.errors[:base]).to include(I18n.t("activerecord.errors.messages.not_editable")) + end + end + + context "when the record has today as valid_from (already in effect)" do + let(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.current) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end + + context "when the current user has manage_own_working_times and owns the record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let(:working_hours) { create(:user_working_hours, user: current_user, valid_from: Date.tomorrow) } + + it "updates the record successfully" do + expect(service_call).to be_success + expect(service_call.result.monday_hours).to eq(5) + end + end + + context "when the current user has manage_own_working_times but targets another user's record" do + let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.tomorrow) } + + it "is unsuccessful" do + expect(service_call).to be_failure + end + end +end From 4fdd8e70ad753953f6df854a66139b127ec3691d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 25 Feb 2026 11:16:15 +0100 Subject: [PATCH 033/435] Add API endpoints to manage user working times --- .../non_working_days_by_user_api.rb | 73 +++++ ..._non_working_day_collection_representer.rb | 34 +++ ...ser_non_working_day_payload_representer.rb | 35 +++ .../user_non_working_day_representer.rb | 61 ++++ ...er_working_hours_collection_representer.rb | 34 +++ .../user_working_hours_payload_representer.rb | 35 +++ .../user_working_hours_representer.rb | 71 +++++ .../working_hours_by_user_api.rb | 69 +++++ lib/api/v3/users/users_api.rb | 4 + lib/api/v3/utilities/path_helper.rb | 18 ++ .../non_working_days_by_user_api_spec.rb | 239 +++++++++++++++ .../working_hours_by_user_api_spec.rb | 275 ++++++++++++++++++ 12 files changed, 948 insertions(+) create mode 100644 lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb create mode 100644 lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb create mode 100644 lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb create mode 100644 lib/api/v3/user_non_working_days/user_non_working_day_representer.rb create mode 100644 lib/api/v3/user_working_hours/user_working_hours_collection_representer.rb create mode 100644 lib/api/v3/user_working_hours/user_working_hours_payload_representer.rb create mode 100644 lib/api/v3/user_working_hours/user_working_hours_representer.rb create mode 100644 lib/api/v3/user_working_hours/working_hours_by_user_api.rb create mode 100644 spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb create mode 100644 spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb diff --git a/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb b/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb new file mode 100644 index 00000000000..99853954aca --- /dev/null +++ b/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module UserNonWorkingDays + class NonWorkingDaysByUserAPI < ::API::OpenProjectAPI + resource :non_working_days do + params do + optional :year, type: Integer, desc: "Filter by year. Defaults to the current year." + end + get do + year = params[:year] || Date.current.year + records = ::UserNonWorkingDay + .visible(current_user) + .for_user(@user) + .for_year(year) + .order(:date) + + UserNonWorkingDayCollectionRepresenter.new( + records, + self_link: api_v3_paths.user_non_working_days(@user.id), + current_user: + ) + end + + post &::API::V3::Utilities::Endpoints::Create.new( + model: ::UserNonWorkingDay, + params_modifier: ->(params) { params.merge(user: @user) } + ).mount + + route_param :date, type: Date, desc: "UserNonWorkingDay date" do + after_validation do + @user_non_working_day = ::UserNonWorkingDay + .visible(current_user) + .for_user(@user) + .find_by!(date: declared_params[:date]) + end + + delete &::API::V3::Utilities::Endpoints::Delete.new(model: ::UserNonWorkingDay).mount + end + end + end + end + end +end diff --git a/lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb b/lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb new file mode 100644 index 00000000000..18dfae15b63 --- /dev/null +++ b/lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API::V3::UserNonWorkingDays + class UserNonWorkingDayCollectionRepresenter < ::API::Decorators::UnpaginatedCollection + end +end diff --git a/lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb b/lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb new file mode 100644 index 00000000000..5bfe6c37cee --- /dev/null +++ b/lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API::V3::UserNonWorkingDays + class UserNonWorkingDayPayloadRepresenter < UserNonWorkingDayRepresenter + include ::API::Utilities::PayloadRepresenter + end +end diff --git a/lib/api/v3/user_non_working_days/user_non_working_day_representer.rb b/lib/api/v3/user_non_working_days/user_non_working_day_representer.rb new file mode 100644 index 00000000000..ad40bdb6e47 --- /dev/null +++ b/lib/api/v3/user_non_working_days/user_non_working_day_representer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module UserNonWorkingDays + class UserNonWorkingDayRepresenter < ::API::Decorators::Single + include ::API::Decorators::DateProperty + include ::API::Decorators::LinkedResource + + link :self do + { + href: api_v3_paths.user_non_working_day(represented.user_id, represented.date) + } + end + + link :user do + { + href: api_v3_paths.user(represented.user_id), + title: represented.user&.name + } + end + + property :id + + date_property :date + + def _type + "UserNonWorkingDay" + end + end + end + end +end diff --git a/lib/api/v3/user_working_hours/user_working_hours_collection_representer.rb b/lib/api/v3/user_working_hours/user_working_hours_collection_representer.rb new file mode 100644 index 00000000000..def7958d4ed --- /dev/null +++ b/lib/api/v3/user_working_hours/user_working_hours_collection_representer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API::V3::UserWorkingHours + class UserWorkingHoursCollectionRepresenter < ::API::Decorators::UnpaginatedCollection + end +end diff --git a/lib/api/v3/user_working_hours/user_working_hours_payload_representer.rb b/lib/api/v3/user_working_hours/user_working_hours_payload_representer.rb new file mode 100644 index 00000000000..4bf2554cee8 --- /dev/null +++ b/lib/api/v3/user_working_hours/user_working_hours_payload_representer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API::V3::UserWorkingHours + class UserWorkingHoursPayloadRepresenter < UserWorkingHoursRepresenter + include ::API::Utilities::PayloadRepresenter + end +end diff --git a/lib/api/v3/user_working_hours/user_working_hours_representer.rb b/lib/api/v3/user_working_hours/user_working_hours_representer.rb new file mode 100644 index 00000000000..a3f9c22368b --- /dev/null +++ b/lib/api/v3/user_working_hours/user_working_hours_representer.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module UserWorkingHours + class UserWorkingHoursRepresenter < ::API::Decorators::Single + include ::API::Decorators::DateProperty + include ::API::Decorators::LinkedResource + + link :self do + { + href: api_v3_paths.user_working_hours_record(represented.user_id, represented.id) + } + end + + link :user do + { + href: api_v3_paths.user(represented.user_id), + title: represented.user&.name + } + end + + property :id + + date_property :valid_from + + property :monday_hours + property :tuesday_hours + property :wednesday_hours + property :thursday_hours + property :friday_hours + property :saturday_hours + property :sunday_hours + + property :availability_factor + + def _type + "UserWorkingHours" + end + end + end + end +end diff --git a/lib/api/v3/user_working_hours/working_hours_by_user_api.rb b/lib/api/v3/user_working_hours/working_hours_by_user_api.rb new file mode 100644 index 00000000000..5e9fceee4f4 --- /dev/null +++ b/lib/api/v3/user_working_hours/working_hours_by_user_api.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module UserWorkingHours + class WorkingHoursByUserAPI < ::API::OpenProjectAPI + resource :working_hours do + get do + records = ::UserWorkingHours.visible(current_user).for_user(@user).order(valid_from: :desc) + + UserWorkingHoursCollectionRepresenter.new( + records, + self_link: api_v3_paths.user_working_hours(@user.id), + current_user: + ) + end + + post &::API::V3::Utilities::Endpoints::Create.new( + model: ::UserWorkingHours, + params_modifier: ->(params) { params.merge(user: @user) } + ).mount + + route_param :working_hours_id, type: Integer, desc: "UserWorkingHours ID" do + after_validation do + @user_working_hours = ::UserWorkingHours + .visible(current_user) + .for_user(@user) + .find(declared_params[:working_hours_id]) + end + + get &::API::V3::Utilities::Endpoints::Show.new(model: ::UserWorkingHours).mount + + patch &::API::V3::Utilities::Endpoints::Update.new(model: ::UserWorkingHours).mount + + delete &::API::V3::Utilities::Endpoints::Delete.new(model: ::UserWorkingHours).mount + end + end + end + end + end +end diff --git a/lib/api/v3/users/users_api.rb b/lib/api/v3/users/users_api.rb index 0b183e037fa..b5a5bf3c988 100644 --- a/lib/api/v3/users/users_api.rb +++ b/lib/api/v3/users/users_api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -88,6 +90,8 @@ module API mount ::API::V3::Users::UpdateFormAPI mount ::API::V3::UserPreferences::PreferencesByUserAPI + mount ::API::V3::UserWorkingHours::WorkingHoursByUserAPI + mount ::API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI namespace :lock do # Authenticate lock transitions diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 23ee036e513..09bcf6054c2 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -592,6 +594,22 @@ module API "#{user(id)}/preferences" end + def self.user_working_hours(user_id) + "#{user(user_id)}/working_hours" + end + + def self.user_working_hours_record(user_id, id) + "#{user_working_hours(user_id)}/#{id}" + end + + def self.user_non_working_days(user_id) + "#{user(user_id)}/non_working_days" + end + + def self.user_non_working_day(user_id, date) + "#{user_non_working_days(user_id)}/#{date}" + end + def self.my_preferences "#{root}/my_preferences" end diff --git a/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb b/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb new file mode 100644 index 00000000000..5fe2590f028 --- /dev/null +++ b/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do + include API::V3::Utilities::PathHelper + + # Admin users can see all users and manage all working times. + let(:admin_user) { create(:admin) } + let(:target_user) { create(:user) } + let(:headers) { { "CONTENT_TYPE" => "application/json" } } + + let!(:non_working_day_last) { create(:user_non_working_day, user: target_user, date: 1.year.ago) } + let!(:non_working_day) { create(:user_non_working_day, user: target_user, date: Date.tomorrow) } + + describe "GET /api/v3/users/:user_id/non_working_days" do + let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + + context "with admin user" do + current_user { admin_user } + + before { get path } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns a collection of non-working days for the current year" do + expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with own user" do + let(:own_user) { create(:user) } + let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } + let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } + + current_user { own_user } + + before { get api_v3_paths.user_non_working_days(own_user.id) } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns only own records" do + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with 'me' as the user ID" do + let(:own_user) { create(:user) } + let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } + let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } + + current_user { own_user } + + before { get api_v3_paths.user_non_working_days("me") } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the same records as using the numeric user ID" do + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { get path } + + it "returns 404 since the user is not visible" do + # The user API returns 404 when User.visible doesn't include the target user + expect(last_response).to have_http_status(404) + end + end + + context "with year filter" do + current_user { admin_user } + + it "returns only current year's records by default" do + get path + expect(last_response).to have_http_status(200) + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + + it "returns the requested year's records when year param is given" do + get "#{path}?year=#{Date.current.year - 1}" + expect(last_response).to have_http_status(200) + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + expect(last_response.body).to be_json_eql(1.year.ago.to_date.iso8601.to_json).at_path("_embedded/elements/0/date") + end + end + + it_behaves_like "handling anonymous user" do + let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + + before { get path } + end + end + + describe "POST /api/v3/users/:user_id/non_working_days" do + let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + let(:new_date) { (Date.tomorrow + 1.week).iso8601 } + let(:valid_params) { { date: new_date } } + + context "with admin user" do + current_user { admin_user } + + before { post path, valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a non-working day for the target user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserNonWorkingDay") + expect(parsed["date"]).to eq(new_date) + end + end + + context "when a system-wide NonWorkingDay exists for the same date" do + let!(:system_non_working_day) { create(:non_working_day, date: Date.parse(new_date)) } + + current_user { admin_user } + + before { post path, valid_params.to_json, headers } + + it "returns 422 Unprocessable Entity" do + expect(last_response).to have_http_status(422) + end + end + + context "with 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + current_user { own_user } + + before { post api_v3_paths.user_non_working_days("me"), valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a non-working day for the current user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserNonWorkingDay") + expect(parsed["date"]).to eq(new_date) + end + end + + context "with regular user targeting another user" do + current_user { create(:user) } + + before { post path, valid_params.to_json, headers } + + it "returns 404 since the target user is not visible" do + expect(last_response).to have_http_status(404) + end + end + end + + describe "DELETE /api/v3/users/:user_id/non_working_days/:date" do + let(:path) { api_v3_paths.user_non_working_day(target_user.id, non_working_day.date) } + + context "with admin user" do + current_user { admin_user } + + before { delete path } + + it "returns 204 No Content" do + expect(last_response).to have_http_status(204) + end + + it "deletes the record" do + expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + end + end + + context "with 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 2.days) } + + current_user { own_user } + + before { delete api_v3_paths.user_non_working_day("me", own_day.date) } + + it "returns 204 No Content" do + expect(last_response).to have_http_status(204) + end + + it "deletes the record" do + expect(UserNonWorkingDay.find_by(id: own_day.id)).to be_nil + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { delete path } + + it "returns 404 since the target user is not visible" do + expect(last_response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb new file mode 100644 index 00000000000..a82d835c337 --- /dev/null +++ b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe API::V3::UserWorkingHours::WorkingHoursByUserAPI do + include API::V3::Utilities::PathHelper + + # Admin users can see all users and manage all working times. + # Regular users with only manage_working_times can't access other users + # via the user API because User.visible restricts visibility. + let(:admin_user) { create(:admin) } + let(:target_user) { create(:user) } + let(:headers) { { "CONTENT_TYPE" => "application/json" } } + + let!(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.yesterday) } + let!(:future_record) { create(:user_working_hours, user: target_user, valid_from: Date.tomorrow) } + + describe "GET /api/v3/users/:user_id/working_hours" do + let(:path) { api_v3_paths.user_working_hours(target_user.id) } + + context "with admin user (has manage_working_times and view_all_principals)" do + current_user { admin_user } + + before { get path } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns a collection of working hours records" do + expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") + expect(last_response.body).to be_json_eql(2.to_json).at_path("total") + end + end + + context "with manage_own_working_times viewing own records" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let!(:own_record) { create(:user_working_hours, user: own_user) } + + current_user { own_user } + + before { get api_v3_paths.user_working_hours(own_user.id) } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the user's own working hours" do + expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with regular user viewing own records (no special permissions)" do + current_user { target_user } + + before { get api_v3_paths.user_working_hours(target_user.id) } + + it "returns 200 with own records (visible scope returns own records)" do + expect(last_response).to have_http_status(200) + expect(last_response.body).to be_json_eql(2.to_json).at_path("total") + end + end + + context "with 'me' as the user ID" do + current_user { target_user } + + before { get api_v3_paths.user_working_hours("me") } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the same records as using the numeric user ID" do + expect(last_response.body).to be_json_eql(2.to_json).at_path("total") + end + end + + it_behaves_like "handling anonymous user" do + let(:path) { api_v3_paths.user_working_hours(target_user.id) } + + before { get path } + end + end + + describe "POST /api/v3/users/:user_id/working_hours" do + let(:path) { api_v3_paths.user_working_hours(target_user.id) } + let(:valid_params) do + { + validFrom: Date.current.iso8601, + mondayHours: 8, + tuesdayHours: 8, + wednesdayHours: 8, + thursdayHours: 8, + fridayHours: 8, + saturdayHours: 0, + sundayHours: 0, + availabilityFactor: 100 + } + end + + context "with admin user" do + current_user { admin_user } + + before { post path, valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a working hours record for the target user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserWorkingHours") + expect(parsed["mondayHours"]).to eq(8.0) + end + end + + context "with own user but no manage_own_working_times permission" do + current_user { target_user } + + before { post api_v3_paths.user_working_hours(target_user.id), valid_params.to_json, headers } + + it "returns 403 Forbidden" do + expect(last_response).to have_http_status(403) + end + end + + context "when 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + current_user { own_user } + + before { post api_v3_paths.user_working_hours("me"), valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a record for the current user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserWorkingHours") + expect(parsed["mondayHours"]).to eq(8.0) + end + end + end + + describe "GET /api/v3/users/:user_id/working_hours/:id" do + let(:path) { api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) } + + context "with admin user" do + current_user { admin_user } + + before { get path } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the working hours record" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserWorkingHours") + expect(parsed["id"]).to eq(working_hours.id) + expect(parsed["mondayHours"]).to eq(8.0) + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { get path } + + it "returns 404 Not Found" do + expect(last_response).to have_http_status(404) + end + end + end + + describe "PATCH /api/v3/users/:user_id/working_hours/:id" do + let(:path) { api_v3_paths.user_working_hours_record(target_user.id, future_record.id) } + let(:params) { { mondayHours: 6 } } + + context "with admin user updating a future record" do + current_user { admin_user } + + before { patch path, params.to_json, headers } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "updates the record" do + parsed = JSON.parse(last_response.body) + expect(parsed["mondayHours"]).to eq(6.0) + end + end + + context "when the record is already in effect (past valid_from)" do + current_user { admin_user } + + before do + patch api_v3_paths.user_working_hours_record(target_user.id, working_hours.id), params.to_json, headers + end + + it "returns 422 Unprocessable Entity" do + expect(last_response).to have_http_status(422) + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { patch path, params.to_json, headers } + + it "returns 404 Not Found" do + expect(last_response).to have_http_status(404) + end + end + end + + describe "DELETE /api/v3/users/:user_id/working_hours/:id" do + let(:path) { api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) } + + context "with admin user" do + current_user { admin_user } + + before { delete path } + + it "returns 204 No Content" do + expect(last_response).to have_http_status(204) + end + + it "deletes the record" do + expect(UserWorkingHours.find_by(id: working_hours.id)).to be_nil + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { delete path } + + it "returns 404 Not Found" do + expect(last_response).to have_http_status(404) + end + end + end +end From fc7441997defdc4d51d4bc1c0aa9d069c9011d5e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 25 Feb 2026 11:16:33 +0100 Subject: [PATCH 034/435] Add API docs --- .../user_non_working_day_collection_model.yml | 59 +++++ .../schemas/user_non_working_day_model.yml | 52 ++++ .../user_working_hours_collection_model.yml | 75 ++++++ .../schemas/user_working_hours_model.yml | 110 +++++++++ docs/api/apiv3/openapi-spec.yml | 17 ++ .../api/apiv3/paths/user_non_working_days.yml | 155 ++++++++++++ .../paths/user_non_working_days_date.yml | 75 ++++++ docs/api/apiv3/paths/user_working_hours.yml | 153 ++++++++++++ .../apiv3/paths/user_working_hours_record.yml | 233 ++++++++++++++++++ docs/api/apiv3/tags/user_working_times.yml | 80 ++++++ .../working_hours_by_user_api_spec.rb | 2 +- 11 files changed, 1010 insertions(+), 1 deletion(-) create mode 100644 docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml create mode 100644 docs/api/apiv3/components/schemas/user_non_working_day_model.yml create mode 100644 docs/api/apiv3/components/schemas/user_working_hours_collection_model.yml create mode 100644 docs/api/apiv3/components/schemas/user_working_hours_model.yml create mode 100644 docs/api/apiv3/paths/user_non_working_days.yml create mode 100644 docs/api/apiv3/paths/user_non_working_days_date.yml create mode 100644 docs/api/apiv3/paths/user_working_hours.yml create mode 100644 docs/api/apiv3/paths/user_working_hours_record.yml create mode 100644 docs/api/apiv3/tags/user_working_times.yml diff --git a/docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml b/docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml new file mode 100644 index 00000000000..2def755f69c --- /dev/null +++ b/docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml @@ -0,0 +1,59 @@ +# Schema: UserNonWorkingDayCollectionModel +--- +allOf: + - $ref: "./collection_model.yml" + - type: object + required: + - _links + - _embedded + properties: + _links: + type: object + required: + - self + properties: + self: + allOf: + - $ref: "./link.yml" + - description: |- + This collection of non-working day records. + **Resource**: UserNonWorkingDayCollectionModel + _embedded: + type: object + required: + - elements + properties: + elements: + type: array + description: |- + The array of personal non-working days for the user, ordered by date ascending. + items: + $ref: "./user_non_working_day_model.yml" + +example: + _type: Collection + total: 2 + count: 2 + _links: + self: + href: /api/v3/users/42/non_working_days + _embedded: + elements: + - _type: UserNonWorkingDay + id: 7 + date: "2025-06-18" + _links: + self: + href: /api/v3/users/42/non_working_days/2025-06-16 + user: + href: /api/v3/users/42 + title: Jane Doe + - _type: UserNonWorkingDay + id: 8 + date: "2025-12-24" + _links: + self: + href: /api/v3/users/42/non_working_days/2025-12-24 + user: + href: /api/v3/users/42 + title: Jane Doe diff --git a/docs/api/apiv3/components/schemas/user_non_working_day_model.yml b/docs/api/apiv3/components/schemas/user_non_working_day_model.yml new file mode 100644 index 00000000000..17b8dc1cf75 --- /dev/null +++ b/docs/api/apiv3/components/schemas/user_non_working_day_model.yml @@ -0,0 +1,52 @@ +# Schema: UserNonWorkingDayModel +--- +type: object +required: + - _type + - id + - date +properties: + _type: + type: string + enum: + - UserNonWorkingDay + id: + type: integer + description: |- + The unique identifier of the non-working day record. + minimum: 1 + date: + type: string + format: date + description: |- + The date of the non-working day in ISO 8601 format (YYYY-MM-DD). + Cannot coincide with a system-wide non-working day. + _links: + type: object + required: + - self + - user + properties: + self: + allOf: + - $ref: './link.yml' + - description: |- + This non-working day record. + **Resource**: UserNonWorkingDay + user: + allOf: + - $ref: './link.yml' + - description: |- + The user this non-working day belongs to. + **Resource**: User + +example: + _type: UserNonWorkingDay + id: 7 + date: '2025-06-16' + _links: + self: + href: /api/v3/users/42/non_working_days/2025-06-16 + user: + href: /api/v3/users/42 + title: Jane Doe diff --git a/docs/api/apiv3/components/schemas/user_working_hours_collection_model.yml b/docs/api/apiv3/components/schemas/user_working_hours_collection_model.yml new file mode 100644 index 00000000000..8715b1b79d4 --- /dev/null +++ b/docs/api/apiv3/components/schemas/user_working_hours_collection_model.yml @@ -0,0 +1,75 @@ +# Schema: UserWorkingHoursCollectionModel +--- +allOf: + - $ref: './collection_model.yml' + - type: object + required: + - _links + - _embedded + properties: + _links: + type: object + required: + - self + properties: + self: + allOf: + - $ref: './link.yml' + - description: |- + This collection of working hours records. + **Resource**: UserWorkingHoursCollectionModel + _embedded: + type: object + required: + - elements + properties: + elements: + type: array + description: |- + The array of working hours records for the user, ordered by `validFrom` descending. + items: + $ref: './user_working_hours_model.yml' + +example: + _type: Collection + total: 2 + count: 2 + _links: + self: + href: /api/v3/users/42/working_hours + _embedded: + elements: + - _type: UserWorkingHours + id: 2 + validFrom: '2025-01-01' + mondayHours: 6.0 + tuesdayHours: 6.0 + wednesdayHours: 6.0 + thursdayHours: 6.0 + fridayHours: 6.0 + saturdayHours: 0.0 + sundayHours: 0.0 + availabilityFactor: 80 + _links: + self: + href: /api/v3/users/42/working_hours/2 + user: + href: /api/v3/users/42 + title: Jane Doe + - _type: UserWorkingHours + id: 1 + validFrom: '2024-01-01' + mondayHours: 8.0 + tuesdayHours: 8.0 + wednesdayHours: 8.0 + thursdayHours: 8.0 + fridayHours: 8.0 + saturdayHours: 0.0 + sundayHours: 0.0 + availabilityFactor: 100 + _links: + self: + href: /api/v3/users/42/working_hours/1 + user: + href: /api/v3/users/42 + title: Jane Doe diff --git a/docs/api/apiv3/components/schemas/user_working_hours_model.yml b/docs/api/apiv3/components/schemas/user_working_hours_model.yml new file mode 100644 index 00000000000..8291fcbf685 --- /dev/null +++ b/docs/api/apiv3/components/schemas/user_working_hours_model.yml @@ -0,0 +1,110 @@ +# Schema: UserWorkingHoursModel +--- +type: object +required: + - _type + - id + - validFrom + - mondayHours + - tuesdayHours + - wednesdayHours + - thursdayHours + - fridayHours + - saturdayHours + - sundayHours + - availabilityFactor +properties: + _type: + type: string + enum: + - UserWorkingHours + id: + type: integer + description: |- + The unique identifier of the working hours record. + minimum: 1 + validFrom: + type: string + format: date + description: |- + The date from which this working hours configuration is in effect (ISO 8601 format). + Multiple records may exist for a user; the one with the latest `validFrom` that is + not in the future is the currently active record. + mondayHours: + type: number + format: float + description: Hours worked on Monday. + minimum: 0 + tuesdayHours: + type: number + format: float + description: Hours worked on Tuesday. + minimum: 0 + wednesdayHours: + type: number + format: float + description: Hours worked on Wednesday. + minimum: 0 + thursdayHours: + type: number + format: float + description: Hours worked on Thursday. + minimum: 0 + fridayHours: + type: number + format: float + description: Hours worked on Friday. + minimum: 0 + saturdayHours: + type: number + format: float + description: Hours worked on Saturday. + minimum: 0 + sundayHours: + type: number + format: float + description: Hours worked on Sunday. + minimum: 0 + availabilityFactor: + type: integer + description: |- + The percentage of working hours the user is available. Must be between 0 and 100. + minimum: 0 + maximum: 100 + _links: + type: object + required: + - self + - user + properties: + self: + allOf: + - $ref: "./link.yml" + - description: |- + This working hours record. + **Resource**: UserWorkingHours + user: + allOf: + - $ref: "./link.yml" + - description: |- + The user this working hours record belongs to. + **Resource**: User + +example: + _type: UserWorkingHours + id: 1 + validFrom: "2024-01-01" + mondayHours: 8.0 + tuesdayHours: 8.0 + wednesdayHours: 8.0 + thursdayHours: 8.0 + fridayHours: 8.0 + saturdayHours: 0.0 + sundayHours: 0.0 + availabilityFactor: 100 + _links: + self: + href: /api/v3/users/42/working_hours/1 + user: + href: /api/v3/users/42 + title: Jane Doe diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 4e2ff787bbc..b3d611f5233 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -482,6 +482,14 @@ paths: "$ref": "./paths/user_form.yml" "/api/v3/users/{id}/lock": "$ref": "./paths/user_lock.yml" + "/api/v3/users/{id}/non_working_days": + "$ref": "./paths/user_non_working_days.yml" + "/api/v3/users/{id}/non_working_days/{date}": + "$ref": "./paths/user_non_working_days_date.yml" + "/api/v3/users/{id}/working_hours": + "$ref": "./paths/user_working_hours.yml" + "/api/v3/users/{id}/working_hours/{working_hours_id}": + "$ref": "./paths/user_working_hours_record.yml" "/api/v3/values/schema/{id}": "$ref": "./paths/values_schema.yml" "/api/v3/versions": @@ -1003,8 +1011,16 @@ components: "$ref": "./components/schemas/user_create_model.yml" UserModel: "$ref": "./components/schemas/user_model.yml" + UserNonWorkingDayCollectionModel: + "$ref": "./components/schemas/user_non_working_day_collection_model.yml" + UserNonWorkingDayModel: + "$ref": "./components/schemas/user_non_working_day_model.yml" UserPreferencesModel: "$ref": "./components/schemas/user_preferences_model.yml" + UserWorkingHoursCollectionModel: + "$ref": "./components/schemas/user_working_hours_collection_model.yml" + UserWorkingHoursModel: + "$ref": "./components/schemas/user_working_hours_model.yml" ValuesPropertyModel: "$ref": "./components/schemas/values_property_model.yml" VersionCollectionModel: @@ -1115,6 +1131,7 @@ tags: - "$ref": "./tags/types.yml" - "$ref": "./tags/userpreferences.yml" - "$ref": "./tags/users.yml" + - "$ref": "./tags/user_working_times.yml" - "$ref": "./tags/values_property.yml" - "$ref": "./tags/versions.yml" - "$ref": "./tags/views.yml" diff --git a/docs/api/apiv3/paths/user_non_working_days.yml b/docs/api/apiv3/paths/user_non_working_days.yml new file mode 100644 index 00000000000..ea6ea5a8478 --- /dev/null +++ b/docs/api/apiv3/paths/user_non_working_days.yml @@ -0,0 +1,155 @@ +# /api/v3/users/{id}/non_working_days +--- +get: + summary: List personal non-working days for a user + operationId: list_user_non_working_days + tags: + - User Working Times + description: |- + Returns all personal non-working days for the given user, ordered by date ascending. + + Personal non-working days mark specific calendar dates as non-working for a user + (e.g., a local holiday or personal day off not covered by the system-wide non-working days). + + **Required permissions:** + - Administrators can view non-working days for any user. + - Users with the global `manage_own_working_times` permission can view their own records. + - Users with the global `manage_working_times` permission can view non-working days for any user. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. + schema: + type: string + example: 42 + - name: year + in: query + required: false + description: |- + Filter results to the given year. Defaults to the current year if not provided. + schema: + type: integer + example: 2025 + responses: + "200": + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_non_working_day_collection_model.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified user does not exist or you do not have permission to view them. + description: Returned if the user does not exist or is not visible to the requesting user. + +post: + summary: Create a personal non-working day for a user + operationId: create_user_non_working_day + tags: + - User Working Times + description: |- + Marks a date as a personal non-working day for the given user. + + The date must not already be a system-wide non-working day. + + **Required permissions:** + - Administrators can create non-working days for any user. + - Users with the global `manage_own_working_times` permission can create records for themselves. + - Users with the global `manage_working_times` permission can create non-working days for any user. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. + schema: + type: string + example: 42 + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/user_non_working_day_model.yml" + example: + date: "2025-06-16" + responses: + "201": + description: Created + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_non_working_day_model.yml" + "400": + $ref: "../components/responses/invalid_request_body.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "403": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** `manage_working_times` globally (for other users) or + `manage_own_working_times` globally (for own records). + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified user does not exist or you do not have permission to view them. + description: Returned if the user does not exist or is not visible to the requesting user. + "406": + $ref: "../components/responses/missing_content_type.yml" + "415": + $ref: "../components/responses/unsupported_media_type.yml" + "422": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:PropertyConstraintViolation + message: Validation failed. + description: |- + Returned if the request body contains invalid parameters, or if the date + is already a system-wide non-working day. diff --git a/docs/api/apiv3/paths/user_non_working_days_date.yml b/docs/api/apiv3/paths/user_non_working_days_date.yml new file mode 100644 index 00000000000..decfde9472d --- /dev/null +++ b/docs/api/apiv3/paths/user_non_working_days_date.yml @@ -0,0 +1,75 @@ +# /api/v3/users/{id}/non_working_days/{date} +--- +delete: + summary: Delete a personal non-working day + operationId: delete_user_non_working_day + tags: + - User Working Times + description: |- + Removes the personal non-working day for the given user and date. + + **Required permissions:** + - Administrators can delete non-working days for any user. + - Users with the global `manage_own_working_times` permission can delete their own records. + - Users with the global `manage_working_times` permission can delete non-working days for any user. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. + schema: + type: string + example: 42 + - name: date + in: path + required: true + description: |- + The date of the personal non-working day to delete, in ISO 8601 format (YYYY-MM-DD). + schema: + type: string + format: date + example: "2025-06-16" + responses: + "204": + description: |- + No Content. + The record was deleted successfully. + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "403": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** `manage_working_times` globally (for other users) or + `manage_own_working_times` globally (for own records). + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the user does not exist, is not visible to the requesting user, + or no personal non-working day exists for the given date. diff --git a/docs/api/apiv3/paths/user_working_hours.yml b/docs/api/apiv3/paths/user_working_hours.yml new file mode 100644 index 00000000000..6f3cb7effb6 --- /dev/null +++ b/docs/api/apiv3/paths/user_working_hours.yml @@ -0,0 +1,153 @@ +# /api/v3/users/{id}/working_hours +--- +get: + summary: List working hours for a user + operationId: list_user_working_hours + tags: + - User Working Times + description: |- + Returns all working hours records for the given user, ordered by `validFrom` descending. + + Multiple records may exist for a user; each represents a period of their working time + configuration. The most recently effective record (the one with the latest `validFrom` + that is not in the future) is used for capacity calculations. + + **Required permissions:** + - Administrators can view working hours for any user. + - Users with the global `manage_working_times` permission can view working hours for any user. + - Any user can view their own working hours records. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. + schema: + type: string + example: 42 + responses: + "200": + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_working_hours_collection_model.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified user does not exist or you do not have permission to view them. + description: |- + Returned if the user does not exist or is not visible to the requesting user. + +post: + summary: Create a working hours record for a user + operationId: create_user_working_hours + tags: + - User Working Times + description: |- + Creates a new working hours record for the given user, effective from the given date. + + **Required permissions:** + - Administrators can create working hours records for any user. + - Users with the global `manage_own_working_times` permission can create records for themselves. + - Users with the global `manage_working_times` permission can create working hours records for any user. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. + schema: + type: string + example: 42 + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/user_working_hours_model.yml" + example: + validFrom: "2025-01-01" + mondayHours: 8 + tuesdayHours: 8 + wednesdayHours: 8 + thursdayHours: 8 + fridayHours: 8 + saturdayHours: 0 + sundayHours: 0 + availabilityFactor: 100 + responses: + "201": + description: Created + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_working_hours_model.yml" + "400": + $ref: "../components/responses/invalid_request_body.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "403": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** `manage_working_times` globally (for other users) or + `manage_own_working_times` globally (for own records). + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified user does not exist or you do not have permission to view them. + description: Returned if the user does not exist or is not visible to the requesting user. + "406": + $ref: "../components/responses/missing_content_type.yml" + "415": + $ref: "../components/responses/unsupported_media_type.yml" + "422": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:PropertyConstraintViolation + message: Validation failed. + description: Returned if the request body contains invalid parameters. diff --git a/docs/api/apiv3/paths/user_working_hours_record.yml b/docs/api/apiv3/paths/user_working_hours_record.yml new file mode 100644 index 00000000000..b9472d06547 --- /dev/null +++ b/docs/api/apiv3/paths/user_working_hours_record.yml @@ -0,0 +1,233 @@ +# /api/v3/users/{id}/working_hours/{working_hours_id} +--- +get: + summary: View a working hours record + operationId: view_user_working_hours_record + tags: + - User Working Times + description: |- + Returns a single working hours record for the given user. + + **Required permissions:** + - Administrators can view working hours records for any user. + - Users with the global `manage_working_times` permission can view working hours for any user. + - Any user can view their own working hours records. + parameters: + - name: id + in: path + required: true + description: User id. + schema: + type: integer + example: 42 + - name: working_hours_id + in: path + required: true + description: Working hours record id. + schema: + type: integer + example: 1 + responses: + "200": + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_working_hours_model.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the user or working hours record does not exist, + or if the requesting user does not have permission to view it. + +patch: + summary: Update a working hours record + operationId: update_user_working_hours_record + tags: + - User Working Times + description: |- + Updates the given working hours record. + + Only records that have not yet taken effect (i.e., `validFrom` is in the future) can be + updated. Attempting to update a record that is already in effect will return a `422` error. + + **Required permissions:** + - Administrators can update working hours records for any user. + - Users with the global `manage_own_working_times` permission can update their own records. + - Users with the global `manage_working_times` permission can update working hours for any user. + parameters: + - name: id + in: path + required: true + description: User id. + schema: + type: integer + example: 42 + - name: working_hours_id + in: path + required: true + description: Working hours record id. + schema: + type: integer + example: 2 + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/user_working_hours_model.yml" + example: + mondayHours: 6 + tuesdayHours: 6 + wednesdayHours: 6 + thursdayHours: 6 + fridayHours: 6 + saturdayHours: 0 + sundayHours: 0 + availabilityFactor: 80 + responses: + "200": + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_working_hours_model.yml" + "400": + $ref: "../components/responses/invalid_request_body.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "403": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** `manage_working_times` globally (for other users) or + `manage_own_working_times` globally (for own records). + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the user or working hours record does not exist, + or if the requesting user does not have permission to view it. + "406": + $ref: "../components/responses/missing_content_type.yml" + "415": + $ref: "../components/responses/unsupported_media_type.yml" + "422": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:PropertyConstraintViolation + message: Validation failed. + description: |- + Returned if the request body contains invalid parameters, or if the record has + already taken effect and cannot be modified. + +delete: + summary: Delete a working hours record + operationId: delete_user_working_hours_record + tags: + - User Working Times + description: |- + Deletes the given working hours record. + + **Required permissions:** + - Administrators can delete working hours records for any user. + - Users with the global `manage_own_working_times` permission can delete their own records. + - Users with the global `manage_working_times` permission can delete working hours records for any user. + parameters: + - name: id + in: path + required: true + description: User id. + schema: + type: integer + example: 42 + - name: working_hours_id + in: path + required: true + description: Working hours record id. + schema: + type: integer + example: 2 + responses: + "204": + description: |- + No Content. + The record was deleted successfully. + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "403": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** `manage_working_times` globally (for other users) or + `manage_own_working_times` globally (for own records). + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the user or working hours record does not exist, + or if the requesting user does not have permission to view it. diff --git a/docs/api/apiv3/tags/user_working_times.yml b/docs/api/apiv3/tags/user_working_times.yml new file mode 100644 index 00000000000..06c9cfef656 --- /dev/null +++ b/docs/api/apiv3/tags/user_working_times.yml @@ -0,0 +1,80 @@ +--- +name: User Working Times +description: |- + User working times allow configuring per-user working hours and personal non-working days, + in addition to the system-wide work schedule. + + A `UserWorkingHours` record defines how many hours a user works on each day of the week, + along with an availability factor, effective from a given date (`validFrom`). Multiple records + can exist for a user, each representing a period of their working time configuration. Only the + most recently effective record (i.e., the one with the latest `validFrom` that is not in the + future) is used for capacity calculations. + + A `UserNonWorkingDay` marks a specific calendar date as a non-working day for a user + (e.g., a personal day off or a local holiday not covered by the system-wide non-working days). + A user cannot have a personal non-working day on a date that is already a system-wide + non-working day. + + ## UserWorkingHours Actions + + | Link | Description | Condition | + | :----: | ----------------------------------- | --------------------------------------------------------------------------------------------- | + | update | Update this working hours record | Record has not yet taken effect (`validFrom` is in the future); **Permission**: see below | + | delete | Delete this working hours record | **Permission**: see below | + + ## UserWorkingHours Linked Properties + + | Link | Description | Type | Constraints | Supported operations | + | :--: | -------------------------------------------------------- | ----------------- | ----------- | -------------------- | + | self | This working hours record | UserWorkingHours | not null | READ | + | user | The user this working hours record belongs to | User | not null | READ | + + ## UserWorkingHours Local Properties + + | Property | Description | Type | Constraints | Supported operations | + | :---------------: | ----------------------------------------------------------------------------------------- | ------- | -------------------- | -------------------- | + | id | The unique identifier of the record | Integer | x > 0 | READ | + | validFrom | The date from which this working hours configuration takes effect (ISO 8601 format) | Date | not null | READ / WRITE | + | mondayHours | Hours worked on Monday | Float | x >= 0 | READ / WRITE | + | tuesdayHours | Hours worked on Tuesday | Float | x >= 0 | READ / WRITE | + | wednesdayHours | Hours worked on Wednesday | Float | x >= 0 | READ / WRITE | + | thursdayHours | Hours worked on Thursday | Float | x >= 0 | READ / WRITE | + | fridayHours | Hours worked on Friday | Float | x >= 0 | READ / WRITE | + | saturdayHours | Hours worked on Saturday | Float | x >= 0 | READ / WRITE | + | sundayHours | Hours worked on Sunday | Float | x >= 0 | READ / WRITE | + | availabilityFactor| Percentage of working hours the user is available (0–100) | Integer | 0 <= x <= 100 | READ / WRITE | + + ## UserWorkingHours Permissions + + - **Administrators** can read and manage working hours for any user. + - Users with the global **`manage_own_working_times`** permission can read and manage their own working hours. + - Users with the global **`manage_working_times`** permission can read and manage working hours for any user. + - All users can read their own working hours records even without a special permission. + - Records that have already taken effect (i.e., `validFrom` is today or in the past) cannot be updated. + + ## UserNonWorkingDay Actions + + | Link | Description | Condition | + | :----: | -------------------------------- | ------------------------ | + | delete | Delete this non-working day | **Permission**: see below | + + ## UserNonWorkingDay Linked Properties + + | Link | Description | Type | Constraints | Supported operations | + | :--: | ---------------------------------------------------- | ------------------ | ----------- | -------------------- | + | self | This non-working day | UserNonWorkingDay | not null | READ | + | user | The user this non-working day belongs to | User | not null | READ | + + ## UserNonWorkingDay Local Properties + + | Property | Description | Type | Constraints | Supported operations | + | :------: | ------------------------------------------------------- | ------- | ----------- | -------------------- | + | id | The unique identifier of the record | Integer | x > 0 | READ | + | date | The date of the non-working day (ISO 8601 format) | Date | not null | READ / WRITE | + + ## UserNonWorkingDay Permissions + + - **Administrators** can read and manage personal non-working days for any user. + - Users with the global **`manage_own_working_times`** permission can read and manage their own non-working days. + - Users with the global **`manage_working_times`** permission can read and manage non-working days for any user. + - A personal non-working day cannot be created for a date that is already a system-wide non-working day. diff --git a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb index a82d835c337..c54d7fbfdc0 100644 --- a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb @@ -153,7 +153,7 @@ RSpec.describe API::V3::UserWorkingHours::WorkingHoursByUserAPI do end end - context "when 'me' as the user ID with manage_own_working_times permission" do + context "with 'me' as the user ID with manage_own_working_times permission" do let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } current_user { own_user } From 5518a468ea05ed40aeb223b8a3014eb1cf975511 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 25 Feb 2026 11:30:39 +0100 Subject: [PATCH 035/435] Add methos to retrieve non working days quickly --- app/models/non_working_day.rb | 2 ++ app/models/user.rb | 11 +++++++ spec/models/user_spec.rb | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/app/models/non_working_day.rb b/app/models/non_working_day.rb index f8c7bd71a14..a5deae3b1be 100644 --- a/app/models/non_working_day.rb +++ b/app/models/non_working_day.rb @@ -31,4 +31,6 @@ class NonWorkingDay < ApplicationRecord validates :name, :date, presence: true validates :date, uniqueness: true + + scope :for_year, ->(year) { where(date: Date.new(year, 1, 1)..Date.new(year, 12, 31)) } end diff --git a/app/models/user.rb b/app/models/user.rb index ffdcbe1c02b..7ae30b7270b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -680,6 +680,17 @@ class User < Principal include Scimitar::Resources::Mixin + def non_working_day_entities_for_year(year) + system_days = NonWorkingDay.for_year(year).to_a + user_days = non_working_days.for_year(year).to_a + system_day_dates = system_days.to_set(&:date) + system_days + user_days.reject { |d| system_day_dates.include?(d.date) } + end + + def non_working_days_for_year(year) + non_working_day_entities_for_year(year).map(&:date) + end + protected # Login must not be aliased value 'me' diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 76a27798eab..bfb037673eb 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1110,4 +1110,60 @@ RSpec.describe User do end end end + + describe "#non_working_day_entities_for_year and #non_working_days_for_year" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:year) { 2025 } + + let!(:system_nwd) { create(:non_working_day, date: Date.new(year, 12, 25)) } + let!(:user_nwd) { create(:user_non_working_day, user:, date: Date.new(year, 6, 16)) } + let!(:other_user_nwd) { create(:user_non_working_day, user: other_user, date: Date.new(year, 7, 4)) } + let!(:other_year_system_nwd) { create(:non_working_day, date: Date.new(year - 1, 12, 25)) } + let!(:other_year_user_nwd) { create(:user_non_working_day, user:, date: Date.new(year - 1, 6, 16)) } + + describe "#non_working_days_for_year" do + subject { user.non_working_days_for_year(year) } + + it "includes system-wide non-working days" do + expect(subject).to include(system_nwd.date) + end + + it "includes the user's own non-working days" do + expect(subject).to include(user_nwd.date) + end + + it "does not include other users' non-working days" do + expect(subject).not_to include(other_user_nwd.date) + end + + it "does not include dates from other years" do + expect(subject).not_to include(other_year_system_nwd.date, other_year_user_nwd.date) + end + + context "when a user non-working day coincides with a system non-working day" do + let!(:duplicate_user_nwd) { create(:user_non_working_day, user:, date: system_nwd.date) } + + it "returns the date only once" do + expect(subject.count { |d| d == system_nwd.date }).to eq(1) + end + end + end + + describe "#non_working_day_entities_for_year" do + subject { user.non_working_day_entities_for_year(year) } + + it "returns NonWorkingDay and UserNonWorkingDay records" do + expect(subject).to include(system_nwd, user_nwd) + end + + it "does not include other users' non-working days" do + expect(subject).not_to include(other_user_nwd) + end + + it "does not include records from other years" do + expect(subject).not_to include(other_year_system_nwd, other_year_user_nwd) + end + end + end end From 2221678e61a62f7c0fa744e8d25b91749521bb25 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 25 Feb 2026 12:11:50 +0100 Subject: [PATCH 036/435] Initial step for /my actions for working times --- .../my/working_times_header_component.rb | 54 +++++++++++++++++++ app/controllers/my_controller.rb | 17 +++++- app/views/my/non_working_days.html.erb | 3 ++ app/views/my/working_hours.html.erb | 7 +++ config/initializers/menus.rb | 4 ++ config/locales/en.yml | 3 ++ config/routes.rb | 3 ++ 7 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 app/components/my/working_times_header_component.rb create mode 100644 app/views/my/non_working_days.html.erb create mode 100644 app/views/my/working_hours.html.erb diff --git a/app/components/my/working_times_header_component.rb b/app/components/my/working_times_header_component.rb new file mode 100644 index 00000000000..5435fca9a63 --- /dev/null +++ b/app/components/my/working_times_header_component.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module My + class WorkingTimesHeaderComponent < ApplicationComponent + def call # rubocop:disable Metrics/AbcSize + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t(:label_schedule_and_availability) } + header.with_breadcrumbs( + [{ href: my_account_path, text: t(:label_my_account) }, + t(:label_schedule_and_availability)] + ) + header.with_tab_nav(label: "label") do |nav| + nav.with_tab(selected: params[:action] == "working_hours", + href: my_working_hours_path) do |tab| + tab.with_text { t(:label_working_hours) } + end + + nav.with_tab(selected: params[:action] == "non_working_days", + href: my_non_working_days_path(year: Date.current.year)) do |tab| + tab.with_text { t(:label_non_working_days) } + end + end + end + end + end +end diff --git a/app/controllers/my_controller.rb b/app/controllers/my_controller.rb index d34e28ef90d..ec0c7c46d7f 100644 --- a/app/controllers/my_controller.rb +++ b/app/controllers/my_controller.rb @@ -50,7 +50,9 @@ class MyController < ApplicationController :change_password, :password_confirmation_dialog, :notifications, - :reminders + :reminders, + :non_working_days, + :working_hours menu_item :account, only: [:account] menu_item :locale, only: [:locale] @@ -58,6 +60,7 @@ class MyController < ApplicationController menu_item :password, only: [:password] menu_item :notifications, only: [:notifications] menu_item :reminders, only: [:reminders] + menu_item :working_hours, only: %i[working_hours non_working_days] def account; end @@ -96,6 +99,16 @@ class MyController < ApplicationController # Configure user's mail reminders def reminders; end + def working_hours + @current_working_hours = @user.working_hours.current + @working_hours = @user.working_hours + end + + def non_working_days + year = (params[:year].presence || Date.current.year).to_i + @non_working_days = @user.non_working_day_entities_for_year(year) + end + private def redirect_if_password_change_not_allowed_for(user) @@ -119,7 +132,7 @@ class MyController < ApplicationController flash[:error] = error_account_update_failed(result) end - redirect_back(fallback_location: my_account_path) + redirect_back_or_to(my_account_path) end def handle_email_changes diff --git a/app/views/my/non_working_days.html.erb b/app/views/my/non_working_days.html.erb new file mode 100644 index 00000000000..d3788c7c789 --- /dev/null +++ b/app/views/my/non_working_days.html.erb @@ -0,0 +1,3 @@ +<%= render(My::WorkingTimesHeaderComponent.new) %> + +
<%= @non_working_days.pretty_inspect %>
diff --git a/app/views/my/working_hours.html.erb b/app/views/my/working_hours.html.erb new file mode 100644 index 00000000000..3d928f29396 --- /dev/null +++ b/app/views/my/working_hours.html.erb @@ -0,0 +1,7 @@ +<%= render(My::WorkingTimesHeaderComponent.new) %> + +

Current Working Hours

+
<%= @current_working_hours.pretty_inspect %>
+ +

All Working Hours

+
<%= @working_hours.pretty_inspect %>
diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 4033af892c9..45703304fb7 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -275,6 +275,10 @@ Redmine::MenuManager.map :my_menu do |menu| { controller: "/my", action: "account" }, caption: :label_account, icon: "person" + menu.push :working_hours, + { controller: "/my", action: "working_hours" }, + caption: :label_schedule_and_availability, + icon: "calendar" menu.push :locale, { controller: "/my", action: "locale" }, caption: :label_locale, diff --git a/config/locales/en.yml b/config/locales/en.yml index 77958488c3e..7164c9060c5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4150,6 +4150,9 @@ en: label_not_changeable: "(not changeable)" label_global: "Global" label_seeded_from_env_warning: This record has been created through a setting environment variable. It is not editable through UI. + label_schedule_and_availability: "Schedule and availability" + label_working_hours: "Work schedule" + label_non_working_days: "Availability calendar" macro_execution_error: "Error executing the macro %{macro_name}" macro_unavailable: "Macro %{macro_name} cannot be displayed." macros: diff --git a/config/routes.rb b/config/routes.rb index fa0494306fc..b70e1bdf9a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1023,6 +1023,9 @@ Rails.application.routes.draw do get "/my/notifications", action: "notifications" get "/my/reminders", action: "reminders" + get "/my/working_hours", action: "working_hours" + get "/my/non_working_days", action: "non_working_days" + patch "/my/account", action: "update_account" patch "/my/settings", action: "update_settings" end From 061adf07b7ebf8e12c45d1fcf4069b3bbdc7bf6d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 25 Feb 2026 12:38:06 +0100 Subject: [PATCH 037/435] Add controllers to allow modifying resources for other users --- .../concerns/working_times_authorization.rb | 22 ++++ .../users/non_working_days_controller.rb | 94 ++++++++++++++ .../users/working_hours_controller.rb | 118 ++++++++++++++++++ .../users/non_working_days/_list.html.erb | 32 +++++ app/views/users/working_hours/_list.html.erb | 37 ++++++ config/initializers/permissions.rb | 10 +- config/routes.rb | 2 + lib/open_project/ui/extensible_tabs.rb | 15 +++ 8 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 app/controllers/concerns/working_times_authorization.rb create mode 100644 app/controllers/users/non_working_days_controller.rb create mode 100644 app/controllers/users/working_hours_controller.rb create mode 100644 app/views/users/non_working_days/_list.html.erb create mode 100644 app/views/users/working_hours/_list.html.erb diff --git a/app/controllers/concerns/working_times_authorization.rb b/app/controllers/concerns/working_times_authorization.rb new file mode 100644 index 00000000000..3fffe0813ad --- /dev/null +++ b/app/controllers/concerns/working_times_authorization.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Shared authorization logic for controllers that manage user working times. +# +# Allows access when the current user has either: +# - the global `manage_working_times` permission (can manage any user), or +# - the global `manage_own_working_times` permission and the target user is themselves. +# +# Requires `@user` to be set before this runs (i.e. `find_user` must precede it +# in the before_action chain). +module WorkingTimesAuthorization + extend ActiveSupport::Concern + + private + + def authorize_manage_working_times + return if current_user.allowed_globally?(:manage_working_times) + return if current_user.allowed_globally?(:manage_own_working_times) && @user == current_user + + deny_access + end +end diff --git a/app/controllers/users/non_working_days_controller.rb b/app/controllers/users/non_working_days_controller.rb new file mode 100644 index 00000000000..59fb49831d0 --- /dev/null +++ b/app/controllers/users/non_working_days_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Users::NonWorkingDaysController < ApplicationController + include WorkingTimesAuthorization + + layout "admin" + + authorization_checked! :index, :create, :destroy + + before_action :find_user + before_action :authorize_manage_working_times + before_action :find_non_working_day, only: %i[destroy] + + def index + @year = (params[:year].presence || Date.current.year).to_i + @non_working_days = @user.non_working_day_entities_for_year(@year) + + render "users/edit" + end + + def create + call = UserNonWorkingDays::CreateService + .new(user: current_user) + .call(**non_working_day_params, user: @user) + + if call.success? + flash[:notice] = I18n.t(:notice_successful_create) + else + flash[:error] = call.errors.full_messages.join(", ") + end + + redirect_to user_non_working_days_path(@user) + end + + def destroy + call = UserNonWorkingDays::DeleteService + .new(model: @user_non_working_day, user: current_user) + .call + + if call.success? + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = call.errors.full_messages.join(", ") + end + + redirect_to user_non_working_days_path(@user) + end + + private + + def find_user + @user = User.visible.find(params[:user_id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def find_non_working_day + @user_non_working_day = @user.non_working_days.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def non_working_day_params + params.expect(non_working_day: [:date]) + end +end diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb new file mode 100644 index 00000000000..c6e18f2a01a --- /dev/null +++ b/app/controllers/users/working_hours_controller.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Users::WorkingHoursController < ApplicationController + include WorkingTimesAuthorization + + layout "admin" + + authorization_checked! :index, :create, :update, :destroy + + before_action :find_user + before_action :authorize_manage_working_times + before_action :find_working_hours, only: %i[update destroy] + + def index + @working_hours = @user.working_hours.order(valid_from: :desc) + @current_working_hours = @user.working_hours.current + + render "users/edit" + end + + def create + call = UserWorkingHours::CreateService + .new(user: current_user) + .call(working_hours_params.merge(user: @user)) + + if call.success? + flash[:notice] = I18n.t(:notice_successful_create) + else + flash[:error] = call.errors.full_messages.join(", ") + end + + redirect_to user_working_hours_index_path(@user) + end + + def update + call = UserWorkingHours::UpdateService + .new(model: @user_working_hours, user: current_user) + .call(working_hours_params) + + if call.success? + flash[:notice] = I18n.t(:notice_successful_update) + else + flash[:error] = call.errors.full_messages.join(", ") + end + + redirect_to user_working_hours_index_path(@user) + end + + def destroy + call = UserWorkingHours::DeleteService + .new(model: @user_working_hours, user: current_user) + .call + + if call.success? + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = call.errors.full_messages.join(", ") + end + + redirect_to user_working_hours_index_path(@user) + end + + private + + def find_user + @user = User.visible.find(params[:user_id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def find_working_hours + @user_working_hours = @user.working_hours.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def working_hours_params + params.expect( + working_hours: %i[valid_from + monday_hours + tuesday_hours + wednesday_hours + thursday_hours + friday_hours + saturday_hours + sunday_hours + availability_factor] + ) + end +end diff --git a/app/views/users/non_working_days/_list.html.erb b/app/views/users/non_working_days/_list.html.erb new file mode 100644 index 00000000000..91bcfbc3337 --- /dev/null +++ b/app/views/users/non_working_days/_list.html.erb @@ -0,0 +1,32 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +

Non-working days for <%= @user.name %> (<%= @year %>)

+ +
<%= @non_working_days.pretty_inspect %>
diff --git a/app/views/users/working_hours/_list.html.erb b/app/views/users/working_hours/_list.html.erb new file mode 100644 index 00000000000..a159085de20 --- /dev/null +++ b/app/views/users/working_hours/_list.html.erb @@ -0,0 +1,37 @@ + +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +

Working hours for <%= @user.name %>

+ +

Current

+
<%= @current_working_hours.pretty_inspect %>
+ +

All records

+
<%= @working_hours.pretty_inspect %>
diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 75233ee0080..e12124b9789 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -288,11 +288,17 @@ Rails.application.reloader.to_prepare do require: :loggedin map.permission :manage_own_working_times, - {}, + { + "users/working_hours": %i[index create update destroy], + "users/non_working_days": %i[index create destroy] + }, permissible_on: :global map.permission :manage_working_times, - {}, + { + "users/working_hours": %i[index create update destroy], + "users/non_working_days": %i[index create destroy] + }, permissible_on: :global end diff --git a/config/routes.rb b/config/routes.rb index b70e1bdf9a9..0f20be8087a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -920,6 +920,8 @@ Rails.application.routes.draw do resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do resources :memberships, controller: "users/memberships", only: %i[update create destroy] + resources :working_hours, controller: "users/working_hours", only: %i[index create update destroy] + resources :non_working_days, controller: "users/non_working_days", only: %i[index create destroy] collection do get "/invite" => "users/invite#start_dialog" diff --git a/lib/open_project/ui/extensible_tabs.rb b/lib/open_project/ui/extensible_tabs.rb index c60e8bacd22..d7ba29e8af8 100644 --- a/lib/open_project/ui/extensible_tabs.rb +++ b/lib/open_project/ui/extensible_tabs.rb @@ -66,6 +66,21 @@ module OpenProject ::Users::UpdateContract.new(context[:user], context[:current_user]).allowed_to_update? } }, + { + name: "working_hours", + partial: "users/working_hours/list", + path: ->(params) { user_working_hours_path(params[:user]) }, + label: :label_working_hours, + only_if: ->(*) { User.current.allowed_globally?(:manage_working_times) } + }, + { + name: "non_working_days", + partial: "users/non_working_days/list", + path: ->(params) { user_non_working_days_path(params[:user]) }, + label: :label_non_working_days, + only_if: ->(*) { User.current.allowed_globally?(:manage_working_times) } + }, + { name: "memberships", partial: "individual_principals/memberships", From 5959386860bfe43e9e50b73df8348618cc78159c Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 26 Feb 2026 10:00:29 +0100 Subject: [PATCH 038/435] Add feature flag for working times --- .../concerns/working_times_authorization.rb | 4 +++ app/controllers/my_controller.rb | 4 +++ .../users/non_working_days_controller.rb | 2 ++ .../users/working_hours_controller.rb | 2 ++ config/initializers/feature_decisions.rb | 4 +++ config/initializers/menus.rb | 3 +- lib/open_project/ui/extensible_tabs.rb | 4 +-- spec/controllers/my_controller_spec.rb | 33 ++++++++++++++++++- 8 files changed, 52 insertions(+), 4 deletions(-) diff --git a/app/controllers/concerns/working_times_authorization.rb b/app/controllers/concerns/working_times_authorization.rb index 3fffe0813ad..c586c43388e 100644 --- a/app/controllers/concerns/working_times_authorization.rb +++ b/app/controllers/concerns/working_times_authorization.rb @@ -19,4 +19,8 @@ module WorkingTimesAuthorization deny_access end + + def check_working_times_feature_flag_is_active + render_403 unless OpenProject::FeatureDecisions.user_working_times_active? + end end diff --git a/app/controllers/my_controller.rb b/app/controllers/my_controller.rb index ec0c7c46d7f..ed682b890e1 100644 --- a/app/controllers/my_controller.rb +++ b/app/controllers/my_controller.rb @@ -100,11 +100,15 @@ class MyController < ApplicationController def reminders; end def working_hours + render_403 unless OpenProject::FeatureDecisions.user_working_times_active? + @current_working_hours = @user.working_hours.current @working_hours = @user.working_hours end def non_working_days + render_403 unless OpenProject::FeatureDecisions.user_working_times_active? + year = (params[:year].presence || Date.current.year).to_i @non_working_days = @user.non_working_day_entities_for_year(year) end diff --git a/app/controllers/users/non_working_days_controller.rb b/app/controllers/users/non_working_days_controller.rb index 59fb49831d0..62fa0a10319 100644 --- a/app/controllers/users/non_working_days_controller.rb +++ b/app/controllers/users/non_working_days_controller.rb @@ -33,6 +33,8 @@ class Users::NonWorkingDaysController < ApplicationController layout "admin" + before :check_working_times_feature_flag_is_active + authorization_checked! :index, :create, :destroy before_action :find_user diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb index c6e18f2a01a..813b9795731 100644 --- a/app/controllers/users/working_hours_controller.rb +++ b/app/controllers/users/working_hours_controller.rb @@ -33,6 +33,8 @@ class Users::WorkingHoursController < ApplicationController layout "admin" + before :check_working_times_feature_flag_is_active + authorization_checked! :index, :create, :update, :destroy before_action :find_user diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 1b10c7c188b..6dec0462938 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -68,3 +68,7 @@ OpenProject::FeatureDecisions.add :jira_import, OpenProject::FeatureDecisions.add :scrum_projects, description: "Enables an overhauled version of the backlogs module to " \ "support Scrum projects with a new sprint planning experience. " + +OpenProject::FeatureDecisions.add :user_working_times, + description: "Enables tracking of user working hours and non-working days.", + force_active: true diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 45703304fb7..0bdf4b07678 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -278,7 +278,8 @@ Redmine::MenuManager.map :my_menu do |menu| menu.push :working_hours, { controller: "/my", action: "working_hours" }, caption: :label_schedule_and_availability, - icon: "calendar" + icon: "calendar", + if: ->(_) { OpenProject::FeatureDecisions.user_working_times_active? } menu.push :locale, { controller: "/my", action: "locale" }, caption: :label_locale, diff --git a/lib/open_project/ui/extensible_tabs.rb b/lib/open_project/ui/extensible_tabs.rb index d7ba29e8af8..cbad77bbed1 100644 --- a/lib/open_project/ui/extensible_tabs.rb +++ b/lib/open_project/ui/extensible_tabs.rb @@ -71,14 +71,14 @@ module OpenProject partial: "users/working_hours/list", path: ->(params) { user_working_hours_path(params[:user]) }, label: :label_working_hours, - only_if: ->(*) { User.current.allowed_globally?(:manage_working_times) } + only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, { name: "non_working_days", partial: "users/non_working_days/list", path: ->(params) { user_non_working_days_path(params[:user]) }, label: :label_non_working_days, - only_if: ->(*) { User.current.allowed_globally?(:manage_working_times) } + only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, { diff --git a/spec/controllers/my_controller_spec.rb b/spec/controllers/my_controller_spec.rb index e9efed08925..689655622f7 100644 --- a/spec/controllers/my_controller_spec.rb +++ b/spec/controllers/my_controller_spec.rb @@ -45,7 +45,7 @@ RSpec.describe MyController do it "renders the password template" do assert_template "password" - assert_response :success + expect(response).to have_http_status(:success) end end @@ -361,4 +361,35 @@ RSpec.describe MyController do expect(response.body).to have_no_css("#menu-sidebar li a", text: "Change password") end end + + describe "#working_times" do + let!(:user_working_hours) { create(:user_working_hours, valid_from: 1.week.ago, user:) } + + subject { get :working_hours } + + context "with feature enabled", with_flag: { user_working_times: true } do + it "responds with success" do + subject + expect(response).to be_successful + end + + it "renders the working_hours template" do + subject + expect(response).to render_template "working_hours" + end + + it "assigns @current_working_hours and @working_hours" do + subject + expect(assigns(:current_working_hours)).to eq(user_working_hours) + expect(assigns(:working_hours)).to eq([user_working_hours]) + end + end + + context "with feature disabled", with_flag: { user_working_times: false } do + it "responds with forbidden" do + subject + expect(response).to have_http_status(:forbidden) + end + end + end end From c064a3653313d5fe07e984421d5f9b371471b323 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 26 Feb 2026 10:07:18 +0100 Subject: [PATCH 039/435] Enforce API feature flag as well --- .../non_working_days_by_user_api.rb | 4 + .../working_hours_by_user_api.rb | 4 + .../non_working_days_by_user_api_spec.rb | 333 ++++++++------- .../working_hours_by_user_api_spec.rb | 387 ++++++++++-------- 4 files changed, 394 insertions(+), 334 deletions(-) diff --git a/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb b/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb index 99853954aca..4a5cd54e335 100644 --- a/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb +++ b/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb @@ -33,6 +33,10 @@ module API module UserNonWorkingDays class NonWorkingDaysByUserAPI < ::API::OpenProjectAPI resource :non_working_days do + after_validation do + guard_feature_flag :user_working_times + end + params do optional :year, type: Integer, desc: "Filter by year. Defaults to the current year." end diff --git a/lib/api/v3/user_working_hours/working_hours_by_user_api.rb b/lib/api/v3/user_working_hours/working_hours_by_user_api.rb index 5e9fceee4f4..892a95a1101 100644 --- a/lib/api/v3/user_working_hours/working_hours_by_user_api.rb +++ b/lib/api/v3/user_working_hours/working_hours_by_user_api.rb @@ -33,6 +33,10 @@ module API module UserWorkingHours class WorkingHoursByUserAPI < ::API::OpenProjectAPI resource :working_hours do + after_validation do + guard_feature_flag :user_working_times + end + get do records = ::UserWorkingHours.visible(current_user).for_user(@user).order(valid_from: :desc) diff --git a/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb b/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb index 5fe2590f028..122a4216bd7 100644 --- a/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb @@ -41,198 +41,219 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do let!(:non_working_day_last) { create(:user_non_working_day, user: target_user, date: 1.year.ago) } let!(:non_working_day) { create(:user_non_working_day, user: target_user, date: Date.tomorrow) } - describe "GET /api/v3/users/:user_id/non_working_days" do - let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + context "with feature disabled", with_flag: { user_working_times: false } do + current_user { admin_user } - context "with admin user" do - current_user { admin_user } - - before { get path } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns a collection of non-working days for the current year" do - expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") - expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - end + it "returns 404 for GET /api/v3/users/:user_id/non_working_days" do + get api_v3_paths.user_non_working_days(target_user.id) + expect(last_response).to have_http_status(404) end - context "with own user" do - let(:own_user) { create(:user) } - let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } - let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } - - current_user { own_user } - - before { get api_v3_paths.user_non_working_days(own_user.id) } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns only own records" do - expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - end + it "returns 404 for POST /api/v3/users/:user_id/non_working_days" do + post api_v3_paths.user_non_working_days(target_user.id), {}.to_json, headers + expect(last_response).to have_http_status(404) end - context "with 'me' as the user ID" do - let(:own_user) { create(:user) } - let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } - let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } - - current_user { own_user } - - before { get api_v3_paths.user_non_working_days("me") } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns the same records as using the numeric user ID" do - expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - end + it "returns 404 for DELETE /api/v3/users/:user_id/non_working_days/:date" do + delete api_v3_paths.user_non_working_day(target_user.id, non_working_day.date) + expect(last_response).to have_http_status(404) end + end - context "with regular user (no access to other users)" do - current_user { create(:user) } - - before { get path } - - it "returns 404 since the user is not visible" do - # The user API returns 404 when User.visible doesn't include the target user - expect(last_response).to have_http_status(404) - end - end - - context "with year filter" do - current_user { admin_user } - - it "returns only current year's records by default" do - get path - expect(last_response).to have_http_status(200) - expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - end - - it "returns the requested year's records when year param is given" do - get "#{path}?year=#{Date.current.year - 1}" - expect(last_response).to have_http_status(200) - expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - expect(last_response.body).to be_json_eql(1.year.ago.to_date.iso8601.to_json).at_path("_embedded/elements/0/date") - end - end - - it_behaves_like "handling anonymous user" do + context "with feature enabled", with_flag: { user_working_times: true } do + describe "GET /api/v3/users/:user_id/non_working_days" do let(:path) { api_v3_paths.user_non_working_days(target_user.id) } - before { get path } - end - end + context "with admin user" do + current_user { admin_user } - describe "POST /api/v3/users/:user_id/non_working_days" do - let(:path) { api_v3_paths.user_non_working_days(target_user.id) } - let(:new_date) { (Date.tomorrow + 1.week).iso8601 } - let(:valid_params) { { date: new_date } } + before { get path } - context "with admin user" do - current_user { admin_user } + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end - before { post path, valid_params.to_json, headers } - - it "returns 201 Created" do - expect(last_response).to have_http_status(201) + it "returns a collection of non-working days for the current year" do + expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end end - it "creates a non-working day for the target user" do - parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserNonWorkingDay") - expect(parsed["date"]).to eq(new_date) + context "with own user" do + let(:own_user) { create(:user) } + let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } + let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } + + current_user { own_user } + + before { get api_v3_paths.user_non_working_days(own_user.id) } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns only own records" do + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with 'me' as the user ID" do + let(:own_user) { create(:user) } + let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } + let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } + + current_user { own_user } + + before { get api_v3_paths.user_non_working_days("me") } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the same records as using the numeric user ID" do + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { get path } + + it "returns 404 since the user is not visible" do + # The user API returns 404 when User.visible doesn't include the target user + expect(last_response).to have_http_status(404) + end + end + + context "with year filter" do + current_user { admin_user } + + it "returns only current year's records by default" do + get path + expect(last_response).to have_http_status(200) + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + + it "returns the requested year's records when year param is given" do + get "#{path}?year=#{Date.current.year - 1}" + expect(last_response).to have_http_status(200) + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + expect(last_response.body).to be_json_eql(1.year.ago.to_date.iso8601.to_json).at_path("_embedded/elements/0/date") + end + end + + it_behaves_like "handling anonymous user" do + let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + + before { get path } end end - context "when a system-wide NonWorkingDay exists for the same date" do - let!(:system_non_working_day) { create(:non_working_day, date: Date.parse(new_date)) } + describe "POST /api/v3/users/:user_id/non_working_days" do + let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + let(:new_date) { (Date.tomorrow + 1.week).iso8601 } + let(:valid_params) { { date: new_date } } - current_user { admin_user } + context "with admin user" do + current_user { admin_user } - before { post path, valid_params.to_json, headers } + before { post path, valid_params.to_json, headers } - it "returns 422 Unprocessable Entity" do - expect(last_response).to have_http_status(422) + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a non-working day for the target user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserNonWorkingDay") + expect(parsed["date"]).to eq(new_date) + end + end + + context "when a system-wide NonWorkingDay exists for the same date" do + let!(:system_non_working_day) { create(:non_working_day, date: Date.parse(new_date)) } + + current_user { admin_user } + + before { post path, valid_params.to_json, headers } + + it "returns 422 Unprocessable Entity" do + expect(last_response).to have_http_status(422) + end + end + + context "with 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + current_user { own_user } + + before { post api_v3_paths.user_non_working_days("me"), valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a non-working day for the current user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserNonWorkingDay") + expect(parsed["date"]).to eq(new_date) + end + end + + context "with regular user targeting another user" do + current_user { create(:user) } + + before { post path, valid_params.to_json, headers } + + it "returns 404 since the target user is not visible" do + expect(last_response).to have_http_status(404) + end end end - context "with 'me' as the user ID with manage_own_working_times permission" do - let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + describe "DELETE /api/v3/users/:user_id/non_working_days/:date" do + let(:path) { api_v3_paths.user_non_working_day(target_user.id, non_working_day.date) } - current_user { own_user } + context "with admin user" do + current_user { admin_user } - before { post api_v3_paths.user_non_working_days("me"), valid_params.to_json, headers } + before { delete path } - it "returns 201 Created" do - expect(last_response).to have_http_status(201) + it "returns 204 No Content" do + expect(last_response).to have_http_status(204) + end + + it "deletes the record" do + expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + end end - it "creates a non-working day for the current user" do - parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserNonWorkingDay") - expect(parsed["date"]).to eq(new_date) - end - end + context "with 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 2.days) } - context "with regular user targeting another user" do - current_user { create(:user) } + current_user { own_user } - before { post path, valid_params.to_json, headers } + before { delete api_v3_paths.user_non_working_day("me", own_day.date) } - it "returns 404 since the target user is not visible" do - expect(last_response).to have_http_status(404) - end - end - end + it "returns 204 No Content" do + expect(last_response).to have_http_status(204) + end - describe "DELETE /api/v3/users/:user_id/non_working_days/:date" do - let(:path) { api_v3_paths.user_non_working_day(target_user.id, non_working_day.date) } - - context "with admin user" do - current_user { admin_user } - - before { delete path } - - it "returns 204 No Content" do - expect(last_response).to have_http_status(204) + it "deletes the record" do + expect(UserNonWorkingDay.find_by(id: own_day.id)).to be_nil + end end - it "deletes the record" do - expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil - end - end + context "with regular user (no access to other users)" do + current_user { create(:user) } - context "with 'me' as the user ID with manage_own_working_times permission" do - let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } - let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 2.days) } + before { delete path } - current_user { own_user } - - before { delete api_v3_paths.user_non_working_day("me", own_day.date) } - - it "returns 204 No Content" do - expect(last_response).to have_http_status(204) - end - - it "deletes the record" do - expect(UserNonWorkingDay.find_by(id: own_day.id)).to be_nil - end - end - - context "with regular user (no access to other users)" do - current_user { create(:user) } - - before { delete path } - - it "returns 404 since the target user is not visible" do - expect(last_response).to have_http_status(404) + it "returns 404 since the target user is not visible" do + expect(last_response).to have_http_status(404) + end end end end diff --git a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb index c54d7fbfdc0..a110a34b6e1 100644 --- a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb @@ -43,232 +43,263 @@ RSpec.describe API::V3::UserWorkingHours::WorkingHoursByUserAPI do let!(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.yesterday) } let!(:future_record) { create(:user_working_hours, user: target_user, valid_from: Date.tomorrow) } - describe "GET /api/v3/users/:user_id/working_hours" do - let(:path) { api_v3_paths.user_working_hours(target_user.id) } + context "with feature disabled", with_flag: { user_working_times: false } do + current_user { admin_user } - context "with admin user (has manage_working_times and view_all_principals)" do - current_user { admin_user } - - before { get path } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns a collection of working hours records" do - expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") - expect(last_response.body).to be_json_eql(2.to_json).at_path("total") - end + it "returns 404 for GET /api/v3/users/:user_id/working_hours" do + get api_v3_paths.user_working_hours(target_user.id) + expect(last_response).to have_http_status(404) end - context "with manage_own_working_times viewing own records" do - let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } - let!(:own_record) { create(:user_working_hours, user: own_user) } - - current_user { own_user } - - before { get api_v3_paths.user_working_hours(own_user.id) } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns the user's own working hours" do - expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") - expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - end + it "returns 404 for POST /api/v3/users/:user_id/working_hours" do + post api_v3_paths.user_working_hours(target_user.id), {}.to_json, headers + expect(last_response).to have_http_status(404) end - context "with regular user viewing own records (no special permissions)" do - current_user { target_user } - - before { get api_v3_paths.user_working_hours(target_user.id) } - - it "returns 200 with own records (visible scope returns own records)" do - expect(last_response).to have_http_status(200) - expect(last_response.body).to be_json_eql(2.to_json).at_path("total") - end + it "returns 404 for GET /api/v3/users/:user_id/working_hours/:id" do + get api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) + expect(last_response).to have_http_status(404) end - context "with 'me' as the user ID" do - current_user { target_user } - - before { get api_v3_paths.user_working_hours("me") } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns the same records as using the numeric user ID" do - expect(last_response.body).to be_json_eql(2.to_json).at_path("total") - end + it "returns 404 for PATCH /api/v3/users/:user_id/working_hours/:id" do + patch api_v3_paths.user_working_hours_record(target_user.id, working_hours.id), {}.to_json, headers + expect(last_response).to have_http_status(404) end - it_behaves_like "handling anonymous user" do + it "returns 404 for DELETE /api/v3/users/:user_id/working_hours/:id" do + delete api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) + expect(last_response).to have_http_status(404) + end + end + + context "with feature enabled", with_flag: { user_working_times: true } do + describe "GET /api/v3/users/:user_id/working_hours" do let(:path) { api_v3_paths.user_working_hours(target_user.id) } - before { get path } - end - end + context "with admin user (has manage_working_times and view_all_principals)" do + current_user { admin_user } - describe "POST /api/v3/users/:user_id/working_hours" do - let(:path) { api_v3_paths.user_working_hours(target_user.id) } - let(:valid_params) do - { - validFrom: Date.current.iso8601, - mondayHours: 8, - tuesdayHours: 8, - wednesdayHours: 8, - thursdayHours: 8, - fridayHours: 8, - saturdayHours: 0, - sundayHours: 0, - availabilityFactor: 100 - } - end + before { get path } - context "with admin user" do - current_user { admin_user } + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end - before { post path, valid_params.to_json, headers } - - it "returns 201 Created" do - expect(last_response).to have_http_status(201) + it "returns a collection of working hours records" do + expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") + expect(last_response.body).to be_json_eql(2.to_json).at_path("total") + end end - it "creates a working hours record for the target user" do - parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserWorkingHours") - expect(parsed["mondayHours"]).to eq(8.0) + context "with manage_own_working_times viewing own records" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let!(:own_record) { create(:user_working_hours, user: own_user) } + + current_user { own_user } + + before { get api_v3_paths.user_working_hours(own_user.id) } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the user's own working hours" do + expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") + expect(last_response.body).to be_json_eql(1.to_json).at_path("total") + end + end + + context "with regular user viewing own records (no special permissions)" do + current_user { target_user } + + before { get api_v3_paths.user_working_hours(target_user.id) } + + it "returns 200 with own records (visible scope returns own records)" do + expect(last_response).to have_http_status(200) + expect(last_response.body).to be_json_eql(2.to_json).at_path("total") + end + end + + context "with 'me' as the user ID" do + current_user { target_user } + + before { get api_v3_paths.user_working_hours("me") } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the same records as using the numeric user ID" do + expect(last_response.body).to be_json_eql(2.to_json).at_path("total") + end + end + + it_behaves_like "handling anonymous user" do + let(:path) { api_v3_paths.user_working_hours(target_user.id) } + + before { get path } end end - context "with own user but no manage_own_working_times permission" do - current_user { target_user } + describe "POST /api/v3/users/:user_id/working_hours" do + let(:path) { api_v3_paths.user_working_hours(target_user.id) } + let(:valid_params) do + { + validFrom: Date.current.iso8601, + mondayHours: 8, + tuesdayHours: 8, + wednesdayHours: 8, + thursdayHours: 8, + fridayHours: 8, + saturdayHours: 0, + sundayHours: 0, + availabilityFactor: 100 + } + end - before { post api_v3_paths.user_working_hours(target_user.id), valid_params.to_json, headers } + context "with admin user" do + current_user { admin_user } - it "returns 403 Forbidden" do - expect(last_response).to have_http_status(403) + before { post path, valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a working hours record for the target user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserWorkingHours") + expect(parsed["mondayHours"]).to eq(8.0) + end + end + + context "with own user but no manage_own_working_times permission" do + current_user { target_user } + + before { post api_v3_paths.user_working_hours(target_user.id), valid_params.to_json, headers } + + it "returns 403 Forbidden" do + expect(last_response).to have_http_status(403) + end + end + + context "with 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + current_user { own_user } + + before { post api_v3_paths.user_working_hours("me"), valid_params.to_json, headers } + + it "returns 201 Created" do + expect(last_response).to have_http_status(201) + end + + it "creates a record for the current user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserWorkingHours") + expect(parsed["mondayHours"]).to eq(8.0) + end end end - context "with 'me' as the user ID with manage_own_working_times permission" do - let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + describe "GET /api/v3/users/:user_id/working_hours/:id" do + let(:path) { api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) } - current_user { own_user } + context "with admin user" do + current_user { admin_user } - before { post api_v3_paths.user_working_hours("me"), valid_params.to_json, headers } + before { get path } - it "returns 201 Created" do - expect(last_response).to have_http_status(201) + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "returns the working hours record" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserWorkingHours") + expect(parsed["id"]).to eq(working_hours.id) + expect(parsed["mondayHours"]).to eq(8.0) + end end - it "creates a record for the current user" do - parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserWorkingHours") - expect(parsed["mondayHours"]).to eq(8.0) - end - end - end + context "with regular user (no access to other users)" do + current_user { create(:user) } - describe "GET /api/v3/users/:user_id/working_hours/:id" do - let(:path) { api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) } + before { get path } - context "with admin user" do - current_user { admin_user } - - before { get path } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) - end - - it "returns the working hours record" do - parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserWorkingHours") - expect(parsed["id"]).to eq(working_hours.id) - expect(parsed["mondayHours"]).to eq(8.0) + it "returns 404 Not Found" do + expect(last_response).to have_http_status(404) + end end end - context "with regular user (no access to other users)" do - current_user { create(:user) } + describe "PATCH /api/v3/users/:user_id/working_hours/:id" do + let(:path) { api_v3_paths.user_working_hours_record(target_user.id, future_record.id) } + let(:params) { { mondayHours: 6 } } - before { get path } + context "with admin user updating a future record" do + current_user { admin_user } - it "returns 404 Not Found" do - expect(last_response).to have_http_status(404) - end - end - end + before { patch path, params.to_json, headers } - describe "PATCH /api/v3/users/:user_id/working_hours/:id" do - let(:path) { api_v3_paths.user_working_hours_record(target_user.id, future_record.id) } - let(:params) { { mondayHours: 6 } } + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end - context "with admin user updating a future record" do - current_user { admin_user } - - before { patch path, params.to_json, headers } - - it "returns 200 OK" do - expect(last_response).to have_http_status(200) + it "updates the record" do + parsed = JSON.parse(last_response.body) + expect(parsed["mondayHours"]).to eq(6.0) + end end - it "updates the record" do - parsed = JSON.parse(last_response.body) - expect(parsed["mondayHours"]).to eq(6.0) + context "when the record is already in effect (past valid_from)" do + current_user { admin_user } + + before do + patch api_v3_paths.user_working_hours_record(target_user.id, working_hours.id), params.to_json, headers + end + + it "returns 422 Unprocessable Entity" do + expect(last_response).to have_http_status(422) + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { patch path, params.to_json, headers } + + it "returns 404 Not Found" do + expect(last_response).to have_http_status(404) + end end end - context "when the record is already in effect (past valid_from)" do - current_user { admin_user } + describe "DELETE /api/v3/users/:user_id/working_hours/:id" do + let(:path) { api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) } - before do - patch api_v3_paths.user_working_hours_record(target_user.id, working_hours.id), params.to_json, headers + context "with admin user" do + current_user { admin_user } + + before { delete path } + + it "returns 204 No Content" do + expect(last_response).to have_http_status(204) + end + + it "deletes the record" do + expect(UserWorkingHours.find_by(id: working_hours.id)).to be_nil + end end - it "returns 422 Unprocessable Entity" do - expect(last_response).to have_http_status(422) - end - end + context "with regular user (no access to other users)" do + current_user { create(:user) } - context "with regular user (no access to other users)" do - current_user { create(:user) } + before { delete path } - before { patch path, params.to_json, headers } - - it "returns 404 Not Found" do - expect(last_response).to have_http_status(404) - end - end - end - - describe "DELETE /api/v3/users/:user_id/working_hours/:id" do - let(:path) { api_v3_paths.user_working_hours_record(target_user.id, working_hours.id) } - - context "with admin user" do - current_user { admin_user } - - before { delete path } - - it "returns 204 No Content" do - expect(last_response).to have_http_status(204) - end - - it "deletes the record" do - expect(UserWorkingHours.find_by(id: working_hours.id)).to be_nil - end - end - - context "with regular user (no access to other users)" do - current_user { create(:user) } - - before { delete path } - - it "returns 404 Not Found" do - expect(last_response).to have_http_status(404) + it "returns 404 Not Found" do + expect(last_response).to have_http_status(404) + end end end end From f6dbdbb7acbf112df7f0df7d4c1a7a3f48bad61d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 27 Feb 2026 11:22:32 +0100 Subject: [PATCH 040/435] Add helpers to summarize schedules --- app/models/user_working_hours.rb | 67 +++++++++++++- spec/models/user_working_hours_spec.rb | 122 +++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb index fccad1a9747..5b5c7b2f178 100644 --- a/app/models/user_working_hours.rb +++ b/app/models/user_working_hours.rb @@ -29,6 +29,10 @@ #++ class UserWorkingHours < ApplicationRecord + DAYS = %i[monday tuesday wednesday thursday friday saturday sunday].freeze + # Maps each day symbol to the Rails I18n date.abbr_day_names index (Sunday = 0) + DAY_ABBR_INDEX = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }.freeze + belongs_to :user, inverse_of: :working_hours validates :valid_from, presence: true @@ -60,7 +64,7 @@ class UserWorkingHours < ApplicationRecord end end - %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| + DAYS.each do |day| define_method("#{day}_hours") do (public_send(day) / 60.0).round(2) end @@ -71,10 +75,69 @@ class UserWorkingHours < ApplicationRecord end def weekly_working_hours - %i[monday tuesday wednesday thursday friday saturday sunday].sum { |day| public_send("#{day}_hours") } + DAYS.sum { |day| public_send("#{day}_hours") } end def effective_weekly_working_hours ((weekly_working_hours * availability_factor) / 100.0).round(2) end + + # Returns the ranges of working days without hours, e.g. "Mon-Fri" or "Mon-Tue, Thu-Fri". + # Days are grouped by whether they are working days (minutes > 0), ignoring hour differences. + def working_day_ranges + DAYS + .map { |day| [day, public_send(day)] } + .chunk_while { |(_, m1), (_, m2)| m1.positive? == m2.positive? } + .select { |group| group.first.last.positive? } + .map { |group| format_day_range(group) } + .join(", ") + end + + # Returns a human-readable summary of working days grouped by consecutive days + # with the same hours, e.g. "Mon-Thu 8h, Fri 6h" or "Mon-Tue 8h, Thu-Fri 8h". + def working_days_summary + DAYS + .map { |day| [day, public_send("#{day}_hours")] } + .chunk_while { |(_, h1), (_, h2)| h1 == h2 } + .reject { |group| group.first.last.zero? } + .map { |group| format_day_group(group) } + .join(", ") + end + + private + + def format_day_range(group) + first_day = group.first.first + last_day = group.last.first + + if group.length == 1 + full_day_name(first_day) + else + "#{full_day_name(first_day)}-#{full_day_name(last_day)}" + end + end + + def format_day_group(group) + first_day = group.first.first + last_day = group.last.first + _, hours = group.first + range = group.length == 1 ? abbr_day_name(first_day) : "#{abbr_day_name(first_day)}-#{abbr_day_name(last_day)}" + "#{range} #{format_hours_str(hours)}" + end + + def format_hours_str(hours) + rounded = hours.round(2) + return "#{rounded.to_i}h" if rounded == rounded.to_i + + separator = I18n.t("number.format.separator") + "#{rounded.to_s.sub('.', separator)}h" + end + + def full_day_name(day) + I18n.t("date.day_names")[DAY_ABBR_INDEX[day]] + end + + def abbr_day_name(day) + I18n.t("date.abbr_day_names")[DAY_ABBR_INDEX[day]] + end end diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb index 96b2e318cec..863e3d922c2 100644 --- a/spec/models/user_working_hours_spec.rb +++ b/spec/models/user_working_hours_spec.rb @@ -198,6 +198,128 @@ RSpec.describe UserWorkingHours do end end + describe "#working_day_ranges" do + def build_hours(**day_minutes) + attrs = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 } + build(:user_working_hours, **attrs, **day_minutes) + end + + it "returns Monday-Friday for a standard work week regardless of hour differences" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 360, thursday: 480, friday: 360) + expect(wh.working_day_ranges).to eq("Monday-Friday") + end + + it "splits ranges at non-working days" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480) + expect(wh.working_day_ranges).to eq("Monday-Tuesday, Thursday-Friday") + end + + it "returns a single day name when only one day is working" do + wh = build_hours(wednesday: 480) + expect(wh.working_day_ranges).to eq("Wednesday") + end + + it "returns an empty string when no days are working" do + wh = build_hours + expect(wh.working_day_ranges).to eq("") + end + + context "with German locale" do + around { |example| I18n.with_locale(:de) { example.run } } + + it "uses full German day names" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480) + expect(wh.working_day_ranges).to eq("Montag-Dienstag, Donnerstag-Freitag") + end + end + end + + describe "#working_days_summary" do + def build_hours(**day_minutes) + attrs = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 } + build(:user_working_hours, **attrs, **day_minutes) + end + + it "returns Mon-Fri 8h when all working days share the same hours" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480) + expect(wh.working_days_summary).to eq("Mon-Fri 8h") + end + + it "returns Mon-Thu 8h, Fri 6h when one day differs" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 360) + expect(wh.working_days_summary).to eq("Mon-Thu 8h, Fri 6h") + end + + it "returns separate segments when multiple groups alternate" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 360, thursday: 480, friday: 360) + expect(wh.working_days_summary).to eq("Mon-Tue 8h, Wed 6h, Thu 8h, Fri 6h") + end + + it "splits into separate ranges when days are missing in the middle" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480) + expect(wh.working_days_summary).to eq("Mon-Tue 8h, Thu-Fri 8h") + end + + it "returns an empty string when no days are working" do + wh = build_hours + expect(wh.working_days_summary).to eq("") + end + + it "returns a single day label when only one day is working" do + wh = build_hours(friday: 480) + expect(wh.working_days_summary).to eq("Fri 8h") + end + + it "formats fractional hours without trailing zeros" do + wh = build_hours(monday: 450, tuesday: 450) + expect(wh.working_days_summary).to eq("Mon-Tue 7.5h") + end + + it "formats whole hours without a decimal" do + wh = build_hours(monday: 480) + expect(wh.working_days_summary).to eq("Mon 8h") + end + + it "includes weekend days when they are working days" do + wh = build_hours(saturday: 240, sunday: 240) + expect(wh.working_days_summary).to eq("Sat-Sun 4h") + end + + it "handles a single weekend day" do + wh = build_hours(saturday: 480) + expect(wh.working_days_summary).to eq("Sat 8h") + end + + context "with German locale" do + around { |example| I18n.with_locale(:de) { example.run } } + + it "uses German abbreviations for a simple Mon-Fri range" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480) + expect(wh.working_days_summary).to eq("Mo-Fr 8h") + end + + it "uses German abbreviations when one day differs" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 360) + expect(wh.working_days_summary).to eq("Mo-Do 8h, Fr 6h") + end + + it "uses German abbreviations when days are missing in the middle" do + wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480) + expect(wh.working_days_summary).to eq("Mo-Di 8h, Do-Fr 8h") + end + + it "uses German abbreviations for weekend days" do + wh = build_hours(saturday: 240, sunday: 240) + expect(wh.working_days_summary).to eq("Sa-So 4h") + end + + it "uses a comma as the decimal separator for fractional hours" do + wh = build_hours(monday: 450, tuesday: 450) + expect(wh.working_days_summary).to eq("Mo-Di 7,5h") + end + end + end + describe ".visible" do let(:user) { create(:user) } let(:other_user) { create(:user) } From 29c3c19c2c54d1e17a22c5631a015d1e59606d3e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 27 Feb 2026 12:20:11 +0100 Subject: [PATCH 041/435] Do not force user_working_times FF to be on --- config/initializers/feature_decisions.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 6dec0462938..3c607f0716c 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -70,5 +70,4 @@ OpenProject::FeatureDecisions.add :scrum_projects, "support Scrum projects with a new sprint planning experience. " OpenProject::FeatureDecisions.add :user_working_times, - description: "Enables tracking of user working hours and non-working days.", - force_active: true + description: "Enables tracking of user working hours and non-working days." From 4a2451e964a52df6df18f07e1db7d0a9b66b8341 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 27 Feb 2026 12:20:52 +0100 Subject: [PATCH 042/435] Implement the overview page for working hours --- .../current_schedule_component.html.erb | 77 +++++++++++++ .../current_schedule_component.rb | 74 +++++++++++++ .../working_hours/schedule_row_component.rb | 83 ++++++++++++++ .../working_hours/schedule_table_component.rb | 101 ++++++++++++++++++ .../stat_card_component.html.erb | 34 ++++++ .../working_hours/stat_card_component.rb | 44 ++++++++ app/controllers/my_controller.rb | 13 ++- app/views/my/working_hours.html.erb | 18 +++- config/locales/en.yml | 31 ++++++ 9 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 app/components/users/working_hours/current_schedule_component.html.erb create mode 100644 app/components/users/working_hours/current_schedule_component.rb create mode 100644 app/components/users/working_hours/schedule_row_component.rb create mode 100644 app/components/users/working_hours/schedule_table_component.rb create mode 100644 app/components/users/working_hours/stat_card_component.html.erb create mode 100644 app/components/users/working_hours/stat_card_component.rb diff --git a/app/components/users/working_hours/current_schedule_component.html.erb b/app/components/users/working_hours/current_schedule_component.html.erb new file mode 100644 index 00000000000..ec14ff98760 --- /dev/null +++ b/app/components/users/working_hours/current_schedule_component.html.erb @@ -0,0 +1,77 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render(Primer::Box.new(border: true, border_radius: 3, bg: :accent, p: 3)) do %> + <% if working_hours.nil? %> + <%= render(Primer::Beta::Text.new(color: :subtle)) do %> + <%= t("users.working_hours.current_schedule.no_schedule") %> + <% end %> + <% else %> + <%= render(Primer::Box.new(display: :flex, justify_content: :space_between, align_items: :center, mb: 3)) do %> + <%= render(Primer::Beta::Heading.new(tag: :h3)) do %> + <%= t("users.working_hours.current_schedule.title") %> + <% end %> + <%= render(Primer::Beta::IconButton.new(icon: :pencil, disabled: true, "aria-label": t("button_edit"))) %> + <% end %> + + <%= render(Primer::Box.new(display: :flex, style: "gap: 12px; flex-wrap: wrap;")) do %> + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.work_days"), + value: working_day_count, + subtitle: working_days_label + ) + ) %> + + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.work_hours"), + value: weekly_hours_label, + subtitle: working_hours.working_days_summary + ) + ) %> + + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.availability_factor"), + value: availability_label, + subtitle: t("users.working_hours.current_schedule.availability_subtitle") + ) + ) %> + + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.effective_hours"), + value: effective_hours_label, + subtitle: t("users.working_hours.current_schedule.effective_subtitle") + ) + ) %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/working_hours/current_schedule_component.rb b/app/components/users/working_hours/current_schedule_component.rb new file mode 100644 index 00000000000..ace76d6a9fb --- /dev/null +++ b/app/components/users/working_hours/current_schedule_component.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module WorkingHours + class CurrentScheduleComponent < ApplicationComponent + def initialize(working_hours:, **) + super(working_hours, **) + @working_hours = working_hours + end + + def working_hours + @working_hours + end + + def working_day_count + UserWorkingHours::DAYS.count { |day| working_hours.public_send(day) > 0 } + end + + def working_days_label + working_hours.working_day_ranges + end + + def availability_label + "#{working_hours.availability_factor}%" + end + + def weekly_hours_label + format_hours(working_hours.weekly_working_hours) + end + + def effective_hours_label + format_hours(working_hours.effective_weekly_working_hours) + end + + private + + def format_hours(hours) + formatted = helpers.number_with_precision(hours, + precision: 2, + strip_insignificant_zeros: true, + separator: I18n.t("number.format.separator")) + "#{formatted}h" + end + end + end +end diff --git a/app/components/users/working_hours/schedule_row_component.rb b/app/components/users/working_hours/schedule_row_component.rb new file mode 100644 index 00000000000..f6e143fd6c1 --- /dev/null +++ b/app/components/users/working_hours/schedule_row_component.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module WorkingHours + class ScheduleRowComponent < OpPrimer::BorderBoxRowComponent + def working_hours + model + end + + def start_date + helpers.format_date(working_hours.valid_from) + end + + def work_days + working_hours.working_days_summary + end + + def work_hours + formatted = helpers.number_with_precision(working_hours.weekly_working_hours, + precision: 2, + strip_insignificant_zeros: true, + separator: I18n.t("number.format.separator")) + "#{formatted}h" + end + + def availability_factor + "#{working_hours.availability_factor}%" + end + + def effective_work_hours + "#{working_hours.effective_weekly_working_hours}h" + end + + def button_links + [action_menu] + end + + def action_menu + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", + "aria-label": t(:label_more), + scheme: :invisible) + + menu.with_item(label: t(:button_edit), href: "#", tag: :a) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + + menu.with_item(label: t(:button_delete), href: "#", tag: :a, scheme: :danger) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end +end diff --git a/app/components/users/working_hours/schedule_table_component.rb b/app/components/users/working_hours/schedule_table_component.rb new file mode 100644 index 00000000000..a769033443c --- /dev/null +++ b/app/components/users/working_hours/schedule_table_component.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module WorkingHours + class ScheduleTableComponent < OpPrimer::BorderBoxTableComponent + columns :start_date, :work_days, :work_hours, :availability_factor, :effective_work_hours + + def initialize(rows:, variant:, **) + super(rows:, **) + @variant = variant + end + + def has_actions? + true + end + + def blank_icon + :calendar + end + + def row_class + Users::WorkingHours::ScheduleRowComponent + end + + def mobile_title + I18n.t("users.working_hours.table.mobile_title") + end + + def headers + [ + [:start_date, { caption: I18n.t("users.working_hours.table.start_date") }], + [:work_days, { caption: I18n.t("users.working_hours.table.work_days") }], + [:work_hours, { caption: I18n.t("users.working_hours.table.work_hours") }], + [:availability_factor, { caption: I18n.t("users.working_hours.table.availability_factor") }], + [:effective_work_hours, { caption: I18n.t("users.working_hours.table.effective_work_hours") }] + ] + end + + def action_row_header_content + return unless @variant == :future + + render(Primer::Beta::IconButton.new( + icon: :plus, + scheme: :invisible, + tag: :a, + href: "#", + "aria-label": I18n.t("users.working_hours.future_schedules.add_button") + )) + end + + def blank_title + I18n.t("users.working_hours.#{@variant}.blank_title") + end + + def blank_description + I18n.t("users.working_hours.#{@variant}.blank_description") + end + + def render_blank_slate + render(Primer::Beta::Blankslate.new(border: false)) do |component| + component.with_visual_icon(icon: :calendar, size: :medium) + component.with_heading(tag: :h2) { blank_title } + component.with_description { blank_description } + if @variant == :future + component.with_primary_action(href: "#") do + I18n.t("users.working_hours.future_schedules.add_button") + end + end + end + end + end + end +end diff --git a/app/components/users/working_hours/stat_card_component.html.erb b/app/components/users/working_hours/stat_card_component.html.erb new file mode 100644 index 00000000000..5e7ff6cd2ff --- /dev/null +++ b/app/components/users/working_hours/stat_card_component.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render(Primer::Box.new(border: true, border_radius: 3, bg: :default, p: 4, flex: 1)) do %> + <%= render(Primer::Beta::Text.new(color: :subtle, font_size: 4, tag: :p)) { label } %> + <%= render(Primer::Beta::Text.new(font_weight: :normal, font_size: 1, mb: 2)) { value } %> + <%= render(Primer::Beta::Text.new(color: :subtle, font_size: :small, tag: :p)) { subtitle } %> +<% end %> diff --git a/app/components/users/working_hours/stat_card_component.rb b/app/components/users/working_hours/stat_card_component.rb new file mode 100644 index 00000000000..5b8d9128840 --- /dev/null +++ b/app/components/users/working_hours/stat_card_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module WorkingHours + class StatCardComponent < ApplicationComponent + attr_reader :label, :value, :subtitle + + def initialize(label:, value:, subtitle:, **) + super(nil, **) + @label = label.to_s + @value = value.to_s + @subtitle = subtitle.to_s + end + end + end +end diff --git a/app/controllers/my_controller.rb b/app/controllers/my_controller.rb index ed682b890e1..3ba5321d173 100644 --- a/app/controllers/my_controller.rb +++ b/app/controllers/my_controller.rb @@ -103,7 +103,18 @@ class MyController < ApplicationController render_403 unless OpenProject::FeatureDecisions.user_working_times_active? @current_working_hours = @user.working_hours.current - @working_hours = @user.working_hours + + @future_working_hours = @user.working_hours + .where(valid_from: (Date.current + 1)..) + .order(valid_from: :asc) + + @past_working_hours = if @current_working_hours + @user.working_hours + .where(valid_from: ...@current_working_hours.valid_from) + .order(valid_from: :desc) + else + UserWorkingHours.none + end end def non_working_days diff --git a/app/views/my/working_hours.html.erb b/app/views/my/working_hours.html.erb index 3d928f29396..8aab5743200 100644 --- a/app/views/my/working_hours.html.erb +++ b/app/views/my/working_hours.html.erb @@ -1,7 +1,17 @@ <%= render(My::WorkingTimesHeaderComponent.new) %> -

Current Working Hours

-
<%= @current_working_hours.pretty_inspect %>
+<%= render(Users::WorkingHours::CurrentScheduleComponent.new(working_hours: @current_working_hours)) %> -

All Working Hours

-
<%= @working_hours.pretty_inspect %>
+<%= render(Primer::Beta::Subhead.new(mt: 4, hide_border: true)) do |subhead| %> + <% subhead.with_heading(tag: :h2) { t("users.working_hours.future_schedules.title") } %> + <% subhead.with_description { t("users.working_hours.future_schedules.description") } %> +<% end %> + +<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @future_working_hours, variant: :future)) %> + +<%= render(Primer::Beta::Subhead.new(mt: 4, hide_border: true)) do |subhead| %> + <% subhead.with_heading(tag: :h2) { t("users.working_hours.history.title") } %> + <% subhead.with_description { t("users.working_hours.history.description") } %> +<% end %> + +<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @past_working_hours, variant: :history)) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 7164c9060c5..5550e1b5ac0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1106,6 +1106,37 @@ en: user: "The user can now log in to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance." placeholder_user: "The placeholder can now be used in %{project}. Meanwhile you can already plan with that user and assign work packages for instance." group: "The group is now a part of %{project}. Meanwhile you can already plan with that group and assign work packages for instance." + working_hours: + current_schedule: + title: "Current schedule" + work_days: "Work days" + work_hours: "Work hours" + availability_factor: "Availability factor" + availability_subtitle: "Dedicated to project work" + effective_hours: "Effective work hours" + effective_subtitle: "Per week" + no_schedule: "No working schedule configured yet" + future_schedules: + title: "Future schedules" + description: "Plan working schedule changes ahead of time. Once the date arrives your working schedules will be updated automatically." + add_button: "Add future schedule" + blank_title: "No future schedules planned" + blank_description: "Create a future schedule to plan changes ahead of time" + history: + title: "Schedule history" + description: "View your past work schedule changes and restore previous working times" + blank_title: "No schedule history yet" + blank_description: "Past schedule changes will appear here" + table: + mobile_title: "Working schedules" + start_date: "Start date" + work_days: "Work days" + work_hours: "Work hours" + availability_factor: "Availability factor" + effective_work_hours: "Effective work hours" + work_days_count: + one: "1 working day" + other: "%{count} working days" page: text: "Text" placeholder_users: From e32a3f65ff1d59455aed77155f785af760da1938 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 27 Feb 2026 17:33:24 +0100 Subject: [PATCH 043/435] Implement frontend for managing working hours --- .../current_schedule_component.html.erb | 82 +++---- .../current_schedule_component.rb | 53 ++++- .../working_hours/dialog_component.html.erb | 45 ++++ .../users/working_hours/dialog_component.rb | 57 +++++ .../working_hours/form_component.html.erb | 202 ++++++++++++++++++ .../users/working_hours/form_component.rb | 85 ++++++++ .../working_hours/schedule_row_component.rb | 45 +++- .../working_hours/schedule_table_component.rb | 21 +- .../user_working_hours/base_contract.rb | 5 + .../user_working_hours/create_contract.rb | 3 + .../user_working_hours/delete_contract.rb | 4 + .../user_working_hours/update_contract.rb | 10 +- app/controllers/my_controller.rb | 8 +- .../users/non_working_days_controller.rb | 2 +- .../users/working_hours_controller.rb | 95 ++++++-- app/models/user_working_hours.rb | 7 +- .../set_attributes_service.rb | 7 + app/views/my/working_hours.html.erb | 10 +- app/views/users/working_hours/_list.html.erb | 18 +- config/locales/en.yml | 24 ++- config/routes.rb | 2 +- .../users/working-hours-form.controller.ts | 170 +++++++++++++++ frontend/src/stimulus/setup.ts | 2 + .../update_contract_spec.rb | 20 +- .../user_working_hours/create_service_spec.rb | 9 + 25 files changed, 883 insertions(+), 103 deletions(-) create mode 100644 app/components/users/working_hours/dialog_component.html.erb create mode 100644 app/components/users/working_hours/dialog_component.rb create mode 100644 app/components/users/working_hours/form_component.html.erb create mode 100644 app/components/users/working_hours/form_component.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts diff --git a/app/components/users/working_hours/current_schedule_component.html.erb b/app/components/users/working_hours/current_schedule_component.html.erb index ec14ff98760..2494bdfac4a 100644 --- a/app/components/users/working_hours/current_schedule_component.html.erb +++ b/app/components/users/working_hours/current_schedule_component.html.erb @@ -28,50 +28,56 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= render(Primer::Box.new(border: true, border_radius: 3, bg: :accent, p: 3)) do %> - <% if working_hours.nil? %> - <%= render(Primer::Beta::Text.new(color: :subtle)) do %> - <%= t("users.working_hours.current_schedule.no_schedule") %> + <%= render(Primer::Box.new(display: :flex, justify_content: :space_between, align_items: :center, mb: 3)) do %> + <%= render(Primer::Beta::Heading.new(tag: :h3)) do %> + <%= t("users.working_hours.current_schedule.title") %> <% end %> - <% else %> - <%= render(Primer::Box.new(display: :flex, justify_content: :space_between, align_items: :center, mb: 3)) do %> - <%= render(Primer::Beta::Heading.new(tag: :h3)) do %> - <%= t("users.working_hours.current_schedule.title") %> - <% end %> + <% if editable? %> + <%= render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + href: create_or_edit_path, + "aria-label": t("button_edit"), + data: { controller: "async-dialog" } + ) + ) %> + <% else %> <%= render(Primer::Beta::IconButton.new(icon: :pencil, disabled: true, "aria-label": t("button_edit"))) %> <% end %> + <% end %> - <%= render(Primer::Box.new(display: :flex, style: "gap: 12px; flex-wrap: wrap;")) do %> - <%= render( - Users::WorkingHours::StatCardComponent.new( - label: t("users.working_hours.current_schedule.work_days"), - value: working_day_count, - subtitle: working_days_label - ) - ) %> + <%= render(Primer::Box.new(display: :flex, style: "gap: 12px; flex-wrap: wrap;")) do %> + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.work_days"), + value: work_days_value, + subtitle: work_days_subtitle + ) + ) %> - <%= render( - Users::WorkingHours::StatCardComponent.new( - label: t("users.working_hours.current_schedule.work_hours"), - value: weekly_hours_label, - subtitle: working_hours.working_days_summary - ) - ) %> + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.work_hours"), + value: weekly_hours_value, + subtitle: weekly_hours_subtitle + ) + ) %> - <%= render( - Users::WorkingHours::StatCardComponent.new( - label: t("users.working_hours.current_schedule.availability_factor"), - value: availability_label, - subtitle: t("users.working_hours.current_schedule.availability_subtitle") - ) - ) %> + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.availability_factor"), + value: availability_value, + subtitle: t("users.working_hours.current_schedule.availability_subtitle") + ) + ) %> - <%= render( - Users::WorkingHours::StatCardComponent.new( - label: t("users.working_hours.current_schedule.effective_hours"), - value: effective_hours_label, - subtitle: t("users.working_hours.current_schedule.effective_subtitle") - ) - ) %> - <% end %> + <%= render( + Users::WorkingHours::StatCardComponent.new( + label: t("users.working_hours.current_schedule.effective_hours"), + value: effective_hours_value, + subtitle: t("users.working_hours.current_schedule.effective_subtitle") + ) + ) %> <% end %> <% end %> diff --git a/app/components/users/working_hours/current_schedule_component.rb b/app/components/users/working_hours/current_schedule_component.rb index ace76d6a9fb..ef60ac54301 100644 --- a/app/components/users/working_hours/current_schedule_component.rb +++ b/app/components/users/working_hours/current_schedule_component.rb @@ -31,32 +31,55 @@ module Users module WorkingHours class CurrentScheduleComponent < ApplicationComponent - def initialize(working_hours:, **) + attr_reader :working_hours, :user + + def initialize(working_hours:, user:, **) super(working_hours, **) @working_hours = working_hours + @user = user end - def working_hours - @working_hours + def editable? + if working_hours && working_hours.valid_from == Date.current + UserWorkingHours::UpdateContract.can_update?(user: User.current, working_hours:) + else + UserWorkingHours::CreateContract.can_create?(user: User.current, target_user: user) + end end - def working_day_count - UserWorkingHours::DAYS.count { |day| working_hours.public_send(day) > 0 } + def work_days_value + return "–" unless working_hours + + UserWorkingHours::DAYS.count { |day| working_hours.public_send(day) > 0 }.to_s end - def working_days_label + def work_days_subtitle + return t("users.working_hours.current_schedule.not_set") unless working_hours + working_hours.working_day_ranges end - def availability_label - "#{working_hours.availability_factor}%" - end + def weekly_hours_value + return "–" unless working_hours - def weekly_hours_label format_hours(working_hours.weekly_working_hours) end - def effective_hours_label + def weekly_hours_subtitle + return t("users.working_hours.current_schedule.not_set") unless working_hours + + working_hours.working_days_summary + end + + def availability_value + return "–" unless working_hours + + "#{working_hours.availability_factor}%" + end + + def effective_hours_value + return "–" unless working_hours + format_hours(working_hours.effective_weekly_working_hours) end @@ -69,6 +92,14 @@ module Users separator: I18n.t("number.format.separator")) "#{formatted}h" end + + def create_or_edit_path + if working_hours && working_hours.valid_from == Date.current + edit_user_working_hour_path(user, working_hours, current: true) + else + new_user_working_hour_path(user, current: true) + end + end end end end diff --git a/app/components/users/working_hours/dialog_component.html.erb b/app/components/users/working_hours/dialog_component.html.erb new file mode 100644 index 00000000000..af38effb877 --- /dev/null +++ b/app/components/users/working_hours/dialog_component.html.erb @@ -0,0 +1,45 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :xlarge)) do |dialog| %> + <% dialog.with_header(variant: :large) %> + <% dialog.with_body do %> + <%= render(Users::WorkingHours::FormComponent.new(user:, working_hours:, show_valid_from:)) %> + <% end %> + <% dialog.with_footer do %> + <%= component_collection do |footer| %> + <% footer.with_component(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) do %> + <%= t(:button_cancel) %> + <% end %> + <% footer.with_component(Primer::Beta::Button.new(scheme: :primary, form: "working-hours-form", type: :submit)) do %> + <%= working_hours.persisted? ? t(:button_save) : t(:button_create) %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/working_hours/dialog_component.rb b/app/components/users/working_hours/dialog_component.rb new file mode 100644 index 00000000000..5e9ecd43df0 --- /dev/null +++ b/app/components/users/working_hours/dialog_component.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module WorkingHours + class DialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "working-hours-dialog" + + attr_reader :user, :working_hours, :show_valid_from + + def initialize(user:, working_hours:, show_valid_from: true, **) + super(nil, **) + @user = user + @working_hours = working_hours + @show_valid_from = show_valid_from + end + + def title + if show_valid_from + t("users.working_hours.form.title") + else + t("users.working_hours.form.title_current") + end + end + end + end +end diff --git a/app/components/users/working_hours/form_component.html.erb b/app/components/users/working_hours/form_component.html.erb new file mode 100644 index 00000000000..d5a1b9460a5 --- /dev/null +++ b/app/components/users/working_hours/form_component.html.erb @@ -0,0 +1,202 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= component_wrapper do %> + <%= form_with( + scope: :working_hours, + url: form_url, + method: form_method, + id: "working-hours-form", + data: { + controller: "users--working-hours-form", + "users--working-hours-form-hours-mode-value": all_same_hours? ? "same" : "individual" + } + ) do |f| %> + + <% if working_hours.errors.any? %> + <%= render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) do %> + <%= working_hours.errors.full_messages.join(", ") %> + <% end %> + <% end %> + + <%# Start date %> + <% if show_valid_from %> +
+ <%= f.label :valid_from, t("users.working_hours.form.start_date"), class: "FormControl-label" %> + <%= f.date_field :valid_from, class: "FormControl-input" %> +
+ + <%= render(OpenProject::Common::DividerComponent.new(mb: 4)) %> + <% end %> + + <%# Work days — horizontal checkboxes, rendered ABOVE the radio buttons %> +
+ <%= t("users.working_hours.form.work_days") %> +
+ <% UserWorkingHours::DAYS.each do |day| %> + <%# Hidden fallback — submits 0 when the number input for this day is disabled %> + + + + <% end %> +
+
+ + <%# Working hours section %> + <%= render(Primer::Beta::Heading.new(tag: :h3, font_size: 4, mb: 3)) do %> + <%= t("users.working_hours.form.working_hours_label") %> + <% end %> + + <%# Hours mode selection %> + <%= render( + Primer::Alpha::RadioButtonGroup.new( + name: "hours_mode", + label: t("users.working_hours.form.hours_mode_label"), + visually_hide_label: true, + mb: 3 + ) + ) do |group| %> + <% group.radio_button( + label: t("users.working_hours.form.same_hours_mode"), + value: "same", + checked: all_same_hours?, + data: { action: "users--working-hours-form#hoursModeChanged" } + ) %> + <% group.radio_button( + label: t("users.working_hours.form.individual_hours_mode"), + value: "individual", + checked: !all_same_hours?, + data: { action: "users--working-hours-form#hoursModeChanged" } + ) %> + <% end %> + + <%# Shared hours input (visible in same hours mode) %> +
> + <%= label_tag "shared_hours", t("users.working_hours.form.work_hours"), class: "FormControl-label" %> +
+ + <%= t("users.working_hours.form.per_day") %> +
+
+ + <%# Individual per-day inputs (visible in individual hours mode) %> +
> + <% UserWorkingHours::DAYS.each do |day| %> +
> + <%= label_tag "working_hours_#{day}_hours", full_day_name(day), class: "FormControl-label" %> +
+ > + <%= t("users.working_hours.form.per_day") %> +
+
+ <% end %> +
+ + <%# Total work hours (calculated, read-only) %> +
+ <%= label_tag "total_work_hours", t("users.working_hours.form.total_work_hours"), class: "FormControl-label" %> +
+ + <%= t("users.working_hours.form.per_week") %> +
+
+ + <%= render(OpenProject::Common::DividerComponent.new(mb: 4)) %> + + <%# Availability section %> + <%= render(Primer::Beta::Heading.new(tag: :h3, font_size: 4, mb: 1)) do %> + <%= t("users.working_hours.form.availability_section") %> + <% end %> +

<%= t("users.working_hours.form.availability_description") %>

+ +
+ <%= f.label :availability_factor, t("users.working_hours.form.availability_factor"), class: "FormControl-label" %> +
+ + % +
+
+ +
+ <%= label_tag "total_available_hours", t("users.working_hours.form.total_available_hours"), class: "FormControl-label" %> +
+ + <%= t("users.working_hours.form.per_week") %> +
+
+ + <% end %> +<% end %> diff --git a/app/components/users/working_hours/form_component.rb b/app/components/users/working_hours/form_component.rb new file mode 100644 index 00000000000..c984f8c4258 --- /dev/null +++ b/app/components/users/working_hours/form_component.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module WorkingHours + class FormComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + attr_reader :user, :working_hours, :show_valid_from + + def initialize(user:, working_hours:, show_valid_from: true, **) + super(nil, **) + @user = user + @working_hours = working_hours + @show_valid_from = show_valid_from + end + + def form_url + url_params = show_valid_from ? {} : { current: true } + + if working_hours.persisted? + user_working_hour_path(user, working_hours, **url_params) + else + user_working_hours_path(user, **url_params) + end + end + + def form_method + working_hours.persisted? ? :patch : :post + end + + def day_enabled?(day) + working_hours.public_send(day) > 0 + end + + def day_hours(day) + working_hours.public_send("#{day}_hours") + end + + def all_same_hours? + enabled = UserWorkingHours::DAYS.select { |d| day_enabled?(d) } + return true if enabled.empty? + + enabled.map { |d| day_hours(d) }.uniq.one? + end + + def shared_hours + first_enabled = UserWorkingHours::DAYS.find { |d| day_enabled?(d) } + first_enabled ? day_hours(first_enabled) : Setting.hours_per_day + end + + def full_day_name(day) + I18n.t("date.day_names")[UserWorkingHours::DAY_ABBR_INDEX[day]] + end + end + end +end diff --git a/app/components/users/working_hours/schedule_row_component.rb b/app/components/users/working_hours/schedule_row_component.rb index f6e143fd6c1..73452abc417 100644 --- a/app/components/users/working_hours/schedule_row_component.rb +++ b/app/components/users/working_hours/schedule_row_component.rb @@ -65,17 +65,44 @@ module Users def action_menu render(Primer::Alpha::ActionMenu.new) do |menu| - menu.with_show_button(icon: "kebab-horizontal", - "aria-label": t(:label_more), - scheme: :invisible) + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(:label_more), scheme: :invisible) + add_edit_item(menu) if can_update? + add_delete_item(menu) if can_delete? + end + end - menu.with_item(label: t(:button_edit), href: "#", tag: :a) do |item| - item.with_leading_visual_icon(icon: :pencil) - end + private - menu.with_item(label: t(:button_delete), href: "#", tag: :a, scheme: :danger) do |item| - item.with_leading_visual_icon(icon: :trash) - end + def can_update? + UserWorkingHours::UpdateContract.can_update?(user: User.current, working_hours:) + end + + def can_delete? + UserWorkingHours::DeleteContract.can_delete?(user: User.current, target_user: table.user) + end + + def add_edit_item(menu) + menu.with_item(label: t(:button_edit), + href: edit_user_working_hour_path(table.user, working_hours), + tag: :a, + content_arguments: { data: { controller: "async-dialog" } }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def add_delete_item(menu) + menu.with_item(label: t(:button_delete), + href: user_working_hour_path(table.user, working_hours), + tag: :a, + scheme: :danger, + content_arguments: { + data: { + "turbo-method": :delete, + "turbo-stream": true, + "turbo-confirm": t("users.working_hours.destroy.confirm") + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) end end end diff --git a/app/components/users/working_hours/schedule_table_component.rb b/app/components/users/working_hours/schedule_table_component.rb index a769033443c..7ca2116c843 100644 --- a/app/components/users/working_hours/schedule_table_component.rb +++ b/app/components/users/working_hours/schedule_table_component.rb @@ -33,9 +33,12 @@ module Users class ScheduleTableComponent < OpPrimer::BorderBoxTableComponent columns :start_date, :work_days, :work_hours, :availability_factor, :effective_work_hours - def initialize(rows:, variant:, **) + attr_reader :variant, :user + + def initialize(rows:, variant:, user:, **) super(rows:, **) @variant = variant + @user = user end def has_actions? @@ -65,14 +68,16 @@ module Users end def action_row_header_content - return unless @variant == :future + return unless variant == :future + return unless UserWorkingHours::CreateContract.can_create?(user: User.current, target_user: user) render(Primer::Beta::IconButton.new( icon: :plus, scheme: :invisible, tag: :a, - href: "#", - "aria-label": I18n.t("users.working_hours.future_schedules.add_button") + href: new_user_working_hour_path(user), + data: { turbo: true, controller: "async-dialog" }, + "aria-label": I18n.t("users.working_hours.future.add_button") )) end @@ -89,9 +94,11 @@ module Users component.with_visual_icon(icon: :calendar, size: :medium) component.with_heading(tag: :h2) { blank_title } component.with_description { blank_description } - if @variant == :future - component.with_primary_action(href: "#") do - I18n.t("users.working_hours.future_schedules.add_button") + if variant == :future && UserWorkingHours::CreateContract.can_create?(user: User.current, target_user: user) + component.with_primary_action(href: new_user_working_hour_path(user), + data: { turbo: true, + controller: "async-dialog" }) do + I18n.t("users.working_hours.future.add_button") end end end diff --git a/app/contracts/user_working_hours/base_contract.rb b/app/contracts/user_working_hours/base_contract.rb index cf69cc683e3..5e439187a3e 100644 --- a/app/contracts/user_working_hours/base_contract.rb +++ b/app/contracts/user_working_hours/base_contract.rb @@ -40,6 +40,11 @@ class UserWorkingHours::BaseContract < ModelContract def self.model = ::UserWorkingHours + def self.can_manage?(user:, target_user:) + user.allowed_globally?(:manage_working_times) || + (target_user.id == user.id && user.allowed_globally?(:manage_own_working_times)) + end + private def validate_manage_permission diff --git a/app/contracts/user_working_hours/create_contract.rb b/app/contracts/user_working_hours/create_contract.rb index b2c587a418a..c0d4aeac883 100644 --- a/app/contracts/user_working_hours/create_contract.rb +++ b/app/contracts/user_working_hours/create_contract.rb @@ -29,4 +29,7 @@ #++ class UserWorkingHours::CreateContract < UserWorkingHours::BaseContract + def self.can_create?(user:, target_user:) + can_manage?(user:, target_user:) + end end diff --git a/app/contracts/user_working_hours/delete_contract.rb b/app/contracts/user_working_hours/delete_contract.rb index 26007e94ffe..448bafdded2 100644 --- a/app/contracts/user_working_hours/delete_contract.rb +++ b/app/contracts/user_working_hours/delete_contract.rb @@ -33,4 +33,8 @@ class UserWorkingHours::DeleteContract < DeleteContract user.allowed_globally?(:manage_working_times) || (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) } + + def self.can_delete?(user:, target_user:) + UserWorkingHours::BaseContract.can_manage?(user:, target_user:) + end end diff --git a/app/contracts/user_working_hours/update_contract.rb b/app/contracts/user_working_hours/update_contract.rb index c94e8f8b449..4f36fd26b57 100644 --- a/app/contracts/user_working_hours/update_contract.rb +++ b/app/contracts/user_working_hours/update_contract.rb @@ -29,18 +29,24 @@ #++ class UserWorkingHours::UpdateContract < UserWorkingHours::BaseContract + attribute :user_id, writable: false + + def self.can_update?(user:, working_hours:) + can_manage?(user:, target_user: working_hours.user) && working_hours.valid_from >= Date.current + end + validate :validate_valid_from_in_future private - # Records that are already in effect (valid_from in the past) cannot be edited. + # Records that started in the past (valid_from before today) cannot be edited. # Use valid_from_was to check the original value before any changes in this request. # Falls back to the current valid_from for new/unsaved records (e.g., in tests). def validate_valid_from_in_future original_valid_from = model.valid_from_was.presence || model.valid_from return if original_valid_from.nil? - unless original_valid_from > Date.current + unless original_valid_from >= Date.current errors.add :base, :not_editable end end diff --git a/app/controllers/my_controller.rb b/app/controllers/my_controller.rb index 3ba5321d173..b8b2a505963 100644 --- a/app/controllers/my_controller.rb +++ b/app/controllers/my_controller.rb @@ -104,14 +104,10 @@ class MyController < ApplicationController @current_working_hours = @user.working_hours.current - @future_working_hours = @user.working_hours - .where(valid_from: (Date.current + 1)..) - .order(valid_from: :asc) + @future_working_hours = @user.working_hours.upcoming_for_display @past_working_hours = if @current_working_hours - @user.working_hours - .where(valid_from: ...@current_working_hours.valid_from) - .order(valid_from: :desc) + @user.working_hours.past_for_display else UserWorkingHours.none end diff --git a/app/controllers/users/non_working_days_controller.rb b/app/controllers/users/non_working_days_controller.rb index 62fa0a10319..b0e8dcc2bdf 100644 --- a/app/controllers/users/non_working_days_controller.rb +++ b/app/controllers/users/non_working_days_controller.rb @@ -33,7 +33,7 @@ class Users::NonWorkingDaysController < ApplicationController layout "admin" - before :check_working_times_feature_flag_is_active + before_action :check_working_times_feature_flag_is_active authorization_checked! :index, :create, :destroy diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb index 813b9795731..b261b6900fb 100644 --- a/app/controllers/users/working_hours_controller.rb +++ b/app/controllers/users/working_hours_controller.rb @@ -30,36 +30,68 @@ class Users::WorkingHoursController < ApplicationController include WorkingTimesAuthorization + include OpTurbo::ComponentStream layout "admin" - before :check_working_times_feature_flag_is_active + before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :create, :update, :destroy + authorization_checked! :index, :new, :edit, :create, :update, :destroy before_action :find_user before_action :authorize_manage_working_times - before_action :find_working_hours, only: %i[update destroy] + before_action :find_working_hours, only: %i[edit update destroy] + before_action :authorize_working_hours_create, only: %i[new create] + before_action :authorize_working_hours_edit, only: %i[edit update] + before_action :authorize_working_hours_delete, only: %i[destroy] def index - @working_hours = @user.working_hours.order(valid_from: :desc) @current_working_hours = @user.working_hours.current + @future_working_hours = @user.working_hours.upcoming_for_display + + @past_working_hours = if @current_working_hours + @user.working_hours.past_for_display + else + UserWorkingHours.none + end + render "users/edit" end + def new + @user_working_hours = build_working_hours_from_system_settings(@user) + + respond_with_dialog( + Users::WorkingHours::DialogComponent.new(user: @user, working_hours: @user_working_hours, + show_valid_from: !current_context?) + ) + end + + def edit + respond_with_dialog( + Users::WorkingHours::DialogComponent.new(user: @user, working_hours: @user_working_hours, + show_valid_from: !current_context?) + ) + end + def create call = UserWorkingHours::CreateService .new(user: current_user) .call(working_hours_params.merge(user: @user)) if call.success? - flash[:notice] = I18n.t(:notice_successful_create) + close_dialog_via_turbo_stream(Users::WorkingHours::DialogComponent::DIALOG_ID) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + update_via_turbo_stream( + component: Users::WorkingHours::FormComponent.new(user: @user, working_hours: call.result, + show_valid_from: !current_context?), + status: :unprocessable_entity + ) end - redirect_to user_working_hours_index_path(@user) + respond_with_turbo_streams end def update @@ -68,12 +100,17 @@ class Users::WorkingHoursController < ApplicationController .call(working_hours_params) if call.success? - flash[:notice] = I18n.t(:notice_successful_update) + close_dialog_via_turbo_stream(Users::WorkingHours::DialogComponent::DIALOG_ID) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + update_via_turbo_stream( + component: Users::WorkingHours::FormComponent.new(user: @user, working_hours: call.result, + show_valid_from: !current_context?), + status: :unprocessable_entity + ) end - redirect_to user_working_hours_index_path(@user) + respond_with_turbo_streams end def destroy @@ -82,16 +119,32 @@ class Users::WorkingHoursController < ApplicationController .call if call.success? - flash[:notice] = I18n.t(:notice_successful_delete) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + render_error_flash_message_via_turbo_stream(message: call.errors.full_messages.join(", ")) end - redirect_to user_working_hours_index_path(@user) + respond_with_turbo_streams end private + def current_context? + params[:current] == "true" + end + + def authorize_working_hours_create + deny_access unless UserWorkingHours::CreateContract.can_create?(user: current_user, target_user: @user) + end + + def authorize_working_hours_edit + deny_access unless UserWorkingHours::UpdateContract.can_update?(user: current_user, working_hours: @user_working_hours) + end + + def authorize_working_hours_delete + deny_access unless UserWorkingHours::DeleteContract.can_delete?(user: current_user, target_user: @user) + end + def find_user @user = User.visible.find(params[:user_id]) rescue ActiveRecord::RecordNotFound @@ -117,4 +170,20 @@ class Users::WorkingHoursController < ApplicationController availability_factor] ) end + + def build_working_hours_from_system_settings(user) + working_day_names = Setting.working_day_names + hours_per_day = Setting.hours_per_day + + day_attrs = UserWorkingHours::DAYS.to_h do |day| + ["#{day}_hours", working_day_names.include?(day) ? hours_per_day : 0] + end + + UserWorkingHours.new( + user: user, + availability_factor: 100, + valid_from: Date.current, + **day_attrs + ) + end end diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb index 5b5c7b2f178..f00f8bddc2e 100644 --- a/app/models/user_working_hours.rb +++ b/app/models/user_working_hours.rb @@ -45,8 +45,11 @@ class UserWorkingHours < ApplicationRecord scope :for_user, ->(user) { where(user:) } - scope :past, -> { where(valid_from: ...Date.current).order(valid_from: :desc) } - scope :upcoming, -> { where(valid_from: Date.current..).order(valid_from: :asc) } + scope :past, ->(date = Date.current) { where(valid_from: ..date).order(valid_from: :desc) } + scope :upcoming, ->(date = Date.current) { where(valid_from: date..).order(valid_from: :asc) } + + scope :upcoming_for_display, -> { upcoming(Date.current + 1).order(valid_from: :asc) } + scope :past_for_display, -> { past(Date.current).order(valid_from: :desc) } def self.valid_for_date(date) where(valid_from: ..date).order(valid_from: :desc).first diff --git a/app/services/user_working_hours/set_attributes_service.rb b/app/services/user_working_hours/set_attributes_service.rb index 686ae5d9d65..b1c9bd49bc3 100644 --- a/app/services/user_working_hours/set_attributes_service.rb +++ b/app/services/user_working_hours/set_attributes_service.rb @@ -29,4 +29,11 @@ #++ class UserWorkingHours::SetAttributesService < BaseServices::SetAttributes + private + + def set_default_attributes(_params) + model.change_by_system do + model.valid_from = Date.current if model.valid_from.nil? + end + end end diff --git a/app/views/my/working_hours.html.erb b/app/views/my/working_hours.html.erb index 8aab5743200..9f5b1a05ea3 100644 --- a/app/views/my/working_hours.html.erb +++ b/app/views/my/working_hours.html.erb @@ -1,17 +1,17 @@ <%= render(My::WorkingTimesHeaderComponent.new) %> -<%= render(Users::WorkingHours::CurrentScheduleComponent.new(working_hours: @current_working_hours)) %> +<%= render(Users::WorkingHours::CurrentScheduleComponent.new(working_hours: @current_working_hours, user: User.current)) %> <%= render(Primer::Beta::Subhead.new(mt: 4, hide_border: true)) do |subhead| %> - <% subhead.with_heading(tag: :h2) { t("users.working_hours.future_schedules.title") } %> - <% subhead.with_description { t("users.working_hours.future_schedules.description") } %> + <% subhead.with_heading(tag: :h2) { t("users.working_hours.future.title") } %> + <% subhead.with_description { t("users.working_hours.future.description") } %> <% end %> -<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @future_working_hours, variant: :future)) %> +<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @future_working_hours, variant: :future, user: User.current)) %> <%= render(Primer::Beta::Subhead.new(mt: 4, hide_border: true)) do |subhead| %> <% subhead.with_heading(tag: :h2) { t("users.working_hours.history.title") } %> <% subhead.with_description { t("users.working_hours.history.description") } %> <% end %> -<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @past_working_hours, variant: :history)) %> +<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @past_working_hours, variant: :history, user: User.current)) %> diff --git a/app/views/users/working_hours/_list.html.erb b/app/views/users/working_hours/_list.html.erb index a159085de20..9d778b0c7af 100644 --- a/app/views/users/working_hours/_list.html.erb +++ b/app/views/users/working_hours/_list.html.erb @@ -28,10 +28,18 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -

Working hours for <%= @user.name %>

+<%= render(Users::WorkingHours::CurrentScheduleComponent.new(working_hours: @current_working_hours, user: @user)) %> -

Current

-
<%= @current_working_hours.pretty_inspect %>
+<%= render(Primer::Beta::Subhead.new(mt: 4, hide_border: true)) do |subhead| %> + <% subhead.with_heading(tag: :h2) { t("users.working_hours.future.title") } %> + <% subhead.with_description { t("users.working_hours.future.description") } %> +<% end %> -

All records

-
<%= @working_hours.pretty_inspect %>
+<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @future_working_hours, variant: :future, user: @user)) %> + +<%= render(Primer::Beta::Subhead.new(mt: 4, hide_border: true)) do |subhead| %> + <% subhead.with_heading(tag: :h2) { t("users.working_hours.history.title") } %> + <% subhead.with_description { t("users.working_hours.history.description") } %> +<% end %> + +<%= render(Users::WorkingHours::ScheduleTableComponent.new(rows: @past_working_hours, variant: :history, user: @user)) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 5550e1b5ac0..21fdd44415f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1115,8 +1115,8 @@ en: availability_subtitle: "Dedicated to project work" effective_hours: "Effective work hours" effective_subtitle: "Per week" - no_schedule: "No working schedule configured yet" - future_schedules: + not_set: "Not set" + future: title: "Future schedules" description: "Plan working schedule changes ahead of time. Once the date arrives your working schedules will be updated automatically." add_button: "Add future schedule" @@ -1127,6 +1127,26 @@ en: description: "View your past work schedule changes and restore previous working times" blank_title: "No schedule history yet" blank_description: "Past schedule changes will appear here" + destroy: + confirm: "Are you sure you want to delete this working schedule?" + form: + title: "Plan a future work schedule" + title_current: "Edit current work schedule" + start_date: "From" + work_days: "Work days" + working_hours_label: "Working hours" + hours_mode_label: "Hours mode" + same_hours_mode: "Same hours per day" + individual_hours_mode: "Individual hours per day" + work_hours: "Work hours" + hours_per_day: "Hours per day" + per_day: "per day" + per_week: "per week" + total_work_hours: "Total work hours" + availability_section: "Availability" + availability_description: "Adjust the percentage of time this user can dedicate to project work." + availability_factor: "Availability factor" + total_available_hours: "Total available work hours" table: mobile_title: "Working schedules" start_date: "Start date" diff --git a/config/routes.rb b/config/routes.rb index 0f20be8087a..9ef509b71c6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -920,7 +920,7 @@ Rails.application.routes.draw do resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do resources :memberships, controller: "users/memberships", only: %i[update create destroy] - resources :working_hours, controller: "users/working_hours", only: %i[index create update destroy] + resources :working_hours, controller: "users/working_hours" resources :non_working_days, controller: "users/non_working_days", only: %i[index create destroy] collection do diff --git a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts new file mode 100644 index 00000000000..f68c3719d25 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts @@ -0,0 +1,170 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import { durationStringToSeconds } from 'core-stimulus/helpers/chronic-duration-helper'; + +export default class WorkingHoursFormController extends Controller { + static targets = [ + 'sameHoursSection', + 'individualSection', + 'sharedHoursInput', + 'dayCheckbox', + 'dayHoursSection', + 'dayHoursInput', + 'totalWorkHoursDisplay', + 'availabilityFactorInput', + 'totalAvailableHoursDisplay', + ]; + + static values = { + hoursMode: { type: String, default: 'same' }, + }; + + declare readonly sameHoursSectionTarget:HTMLElement; + declare readonly individualSectionTarget:HTMLElement; + declare readonly sharedHoursInputTarget:HTMLInputElement; + declare readonly dayCheckboxTargets:HTMLInputElement[]; + declare readonly dayHoursSectionTargets:HTMLElement[]; + declare readonly dayHoursInputTargets:HTMLInputElement[]; + declare readonly totalWorkHoursDisplayTarget:HTMLInputElement; + declare readonly availabilityFactorInputTarget:HTMLInputElement; + declare readonly totalAvailableHoursDisplayTarget:HTMLInputElement; + declare hoursModeValue:string; + + connect() { + this.recalculate(); + } + + hoursModeChanged(event:Event) { + this.hoursModeValue = (event.target as HTMLInputElement).value; + this.updateDisplayMode(); + if (this.hoursModeValue === 'same') { + this.syncSameHoursToAllDays(); + } + this.recalculate(); + } + + dayToggled(event:Event) { + const checkbox = event.target as HTMLInputElement; + const day = checkbox.dataset.day!; + const hoursInput = this.dayHoursInputForDay(day); + const hoursSection = this.dayHoursSectionForDay(day); + if (hoursInput) { + hoursInput.disabled = !checkbox.checked; + } + if (hoursSection) { + hoursSection.hidden = !checkbox.checked; + } + this.recalculate(); + } + + hoursChanged() { + if (this.hoursModeValue === 'same') { + this.syncSameHoursToAllDays(); + } + this.recalculate(); + } + + // Triggered on blur: parse the entered duration string and reformat as a plain decimal hours value. + // This lets users type "4:30", "4h30min", "4,5", etc. — same logic as the time entry form. + hoursFormatted(event:Event) { + const input = event.target as HTMLInputElement; + const seconds = durationStringToSeconds(input.value); + const hours = Math.round(seconds / 3600 * 100) / 100; + input.value = hours > 0 ? String(hours) : ''; + + if (this.hoursModeValue === 'same') { + this.syncSameHoursToAllDays(); + } + this.recalculate(); + } + + availabilityChanged() { + this.recalculate(); + } + + private updateDisplayMode() { + const isSame = this.hoursModeValue === 'same'; + this.sameHoursSectionTarget.hidden = !isSame; + this.individualSectionTarget.hidden = isSame; + } + + private syncSameHoursToAllDays() { + const seconds = durationStringToSeconds(this.sharedHoursInputTarget.value); + const hours = Math.round(seconds / 3600 * 100) / 100; + this.dayHoursInputTargets.forEach((input) => { + const checkbox = this.dayCheckboxForDay(input.dataset.day!); + if (checkbox?.checked) { + input.value = String(hours); + } + }); + } + + private recalculate() { + let totalHours = 0; + + if (this.hoursModeValue === 'same') { + const seconds = durationStringToSeconds(this.sharedHoursInputTarget.value); + const checkedCount = this.dayCheckboxTargets.filter((cb) => cb.checked).length; + totalHours = (seconds / 3600) * checkedCount; + } else { + this.dayHoursInputTargets.forEach((input) => { + const checkbox = this.dayCheckboxForDay(input.dataset.day!); + if (checkbox?.checked) { + totalHours += durationStringToSeconds(input.value) / 3600; + } + }); + } + + this.totalWorkHoursDisplayTarget.value = this.formatHours(totalHours); + + const factor = parseFloat(this.availabilityFactorInputTarget.value); + const available = totalHours * (isNaN(factor) ? 100 : factor) / 100; + this.totalAvailableHoursDisplayTarget.value = this.formatHours(available); + } + + private formatHours(hours:number):string { + const rounded = Math.round(hours * 100) / 100; + return `${rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(2).replace(/0+$/, '')}h`; + } + + private dayHoursInputForDay(day:string):HTMLInputElement|undefined { + return this.dayHoursInputTargets.find((el) => el.dataset.day === day); + } + + private dayHoursSectionForDay(day:string):HTMLElement|undefined { + return this.dayHoursSectionTargets.find((el) => el.dataset.day === day); + } + + private dayCheckboxForDay(day:string):HTMLInputElement|undefined { + return this.dayCheckboxTargets.find((el) => el.dataset.day === day); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 9467ed961d4..074b1a30c4e 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -25,6 +25,7 @@ import StemsController from './controllers/dynamic/work-packages/activities-tab/ import EditorController from './controllers/dynamic/work-packages/activities-tab/editor.controller'; import LazyPageController from './controllers/dynamic/work-packages/activities-tab/lazy-page.controller'; import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; +import WorkingHoursFormController from './controllers/dynamic/users/working-hours-form.controller'; import AutoSubmit from '@stimulus-components/auto-submit'; import RevealController from '@stimulus-components/reveal'; @@ -82,6 +83,7 @@ OpenProjectStimulusApplication.preregister('external-links', ExternalLinksContro OpenProjectStimulusApplication.preregister('highlight-target-element', HighlightTargetElementController); OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeController); OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); +OpenProjectStimulusApplication.preregister('users--working-hours-form', WorkingHoursFormController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); diff --git a/spec/contracts/user_working_hours/update_contract_spec.rb b/spec/contracts/user_working_hours/update_contract_spec.rb index 9dbca71d468..6734c9e36e2 100644 --- a/spec/contracts/user_working_hours/update_contract_spec.rb +++ b/spec/contracts/user_working_hours/update_contract_spec.rb @@ -55,7 +55,7 @@ RSpec.describe UserWorkingHours::UpdateContract do context "when valid_from is today" do let(:valid_from) { Date.current } - it_behaves_like "contract is invalid", base: :not_editable + it_behaves_like "contract is valid" end context "when valid_from is in the past" do @@ -64,6 +64,16 @@ RSpec.describe UserWorkingHours::UpdateContract do it_behaves_like "contract is invalid", base: :not_editable end + context "when valid_from was changed from today to future" do + let(:valid_from) { Date.current } + + before do + working_hours.valid_from = Date.tomorrow + end + + it_behaves_like "contract is valid" + end + context "when valid_from was changed from past to future" do let(:valid_from) { Date.yesterday } @@ -109,5 +119,13 @@ RSpec.describe UserWorkingHours::UpdateContract do it_behaves_like "contract is invalid", base: :error_unauthorized end + context "when the user is changed" do + before do + working_hours.user = build_stubbed(:user) + end + + it_behaves_like "contract is invalid", user_id: :error_readonly + end + include_examples "contract reuses the model errors" end diff --git a/spec/services/user_working_hours/create_service_spec.rb b/spec/services/user_working_hours/create_service_spec.rb index ce15dba540a..d0aab8f6024 100644 --- a/spec/services/user_working_hours/create_service_spec.rb +++ b/spec/services/user_working_hours/create_service_spec.rb @@ -68,6 +68,15 @@ RSpec.describe UserWorkingHours::CreateService do expect(service_call.result.valid_from).to eq(Date.tomorrow) expect(service_call.result.monday_hours).to eq(8) end + + context "when valid_from is not provided" do + let(:params) { super().except(:valid_from) } + + it "defaults valid_from to today" do + expect(service_call).to be_success + expect(service_call.result.valid_from).to eq(Date.current) + end + end end context "when the current user has manage_own_working_times for their own record" do From 83a92ba250849416eca14e41b41d67c9e127f5f4 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 27 Feb 2026 17:48:18 +0100 Subject: [PATCH 044/435] Fix display of history to include current, unique constraint --- app/controllers/my_controller.rb | 4 ++-- .../users/working_hours_controller.rb | 24 ++++++++++++++++--- app/models/user_working_hours.rb | 5 ++-- ..._index_to_user_working_hours_valid_from.rb | 7 ++++++ .../users/working-hours-form.controller.ts | 3 +++ 5 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260227164504_add_unique_index_to_user_working_hours_valid_from.rb diff --git a/app/controllers/my_controller.rb b/app/controllers/my_controller.rb index b8b2a505963..ecf65b64322 100644 --- a/app/controllers/my_controller.rb +++ b/app/controllers/my_controller.rb @@ -104,10 +104,10 @@ class MyController < ApplicationController @current_working_hours = @user.working_hours.current - @future_working_hours = @user.working_hours.upcoming_for_display + @future_working_hours = @user.working_hours.upcoming(Date.current + 1) @past_working_hours = if @current_working_hours - @user.working_hours.past_for_display + @user.working_hours.history_for(@current_working_hours) else UserWorkingHours.none end diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb index b261b6900fb..ca7da6e1833 100644 --- a/app/controllers/users/working_hours_controller.rb +++ b/app/controllers/users/working_hours_controller.rb @@ -48,10 +48,10 @@ class Users::WorkingHoursController < ApplicationController def index @current_working_hours = @user.working_hours.current - @future_working_hours = @user.working_hours.upcoming_for_display + @future_working_hours = @user.working_hours.upcoming(Date.current + 1) @past_working_hours = if @current_working_hours - @user.working_hours.past_for_display + @user.working_hours.history_for(@current_working_hours) else UserWorkingHours.none end @@ -60,7 +60,11 @@ class Users::WorkingHoursController < ApplicationController end def new - @user_working_hours = build_working_hours_from_system_settings(@user) + @user_working_hours = if current_context? + duplicate_current_working_hours(@user) + else + build_working_hours_from_system_settings(@user) + end respond_with_dialog( Users::WorkingHours::DialogComponent.new(user: @user, working_hours: @user_working_hours, @@ -171,6 +175,20 @@ class Users::WorkingHoursController < ApplicationController ) end + def duplicate_current_working_hours(user) + current = user.working_hours.current + return build_working_hours_from_system_settings(user) unless current + + day_attrs = UserWorkingHours::DAYS.to_h { |day| ["#{day}_hours", current.public_send("#{day}_hours")] } + + UserWorkingHours.new( + user:, + availability_factor: current.availability_factor, + valid_from: Date.current, + **day_attrs + ) + end + def build_working_hours_from_system_settings(user) working_day_names = Setting.working_day_names hours_per_day = Setting.hours_per_day diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb index f00f8bddc2e..9c7f890ecbd 100644 --- a/app/models/user_working_hours.rb +++ b/app/models/user_working_hours.rb @@ -35,7 +35,7 @@ class UserWorkingHours < ApplicationRecord belongs_to :user, inverse_of: :working_hours - validates :valid_from, presence: true + validates :valid_from, presence: true, uniqueness: { scope: :user_id } validates :monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 24 * 60 } @@ -48,8 +48,7 @@ class UserWorkingHours < ApplicationRecord scope :past, ->(date = Date.current) { where(valid_from: ..date).order(valid_from: :desc) } scope :upcoming, ->(date = Date.current) { where(valid_from: date..).order(valid_from: :asc) } - scope :upcoming_for_display, -> { upcoming(Date.current + 1).order(valid_from: :asc) } - scope :past_for_display, -> { past(Date.current).order(valid_from: :desc) } + scope :history_for, ->(current_record) { where(valid_from: ..current_record.valid_from).order(valid_from: :desc) } def self.valid_for_date(date) where(valid_from: ..date).order(valid_from: :desc).first diff --git a/db/migrate/20260227164504_add_unique_index_to_user_working_hours_valid_from.rb b/db/migrate/20260227164504_add_unique_index_to_user_working_hours_valid_from.rb new file mode 100644 index 00000000000..9dcc9c5a4dc --- /dev/null +++ b/db/migrate/20260227164504_add_unique_index_to_user_working_hours_valid_from.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddUniqueIndexToUserWorkingHoursValidFrom < ActiveRecord::Migration[8.1] + def change + add_index :user_working_hours, %i[user_id valid_from], unique: true + end +end diff --git a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts index f68c3719d25..493713e959d 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts @@ -83,6 +83,9 @@ export default class WorkingHoursFormController extends Controller { if (hoursSection) { hoursSection.hidden = !checkbox.checked; } + if (this.hoursModeValue === 'same') { + this.syncSameHoursToAllDays(); + } this.recalculate(); } From 9c5932aaf8a5dde9e8fabf80044c8a1d3d7b1b20 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 2 Mar 2026 13:59:38 +0100 Subject: [PATCH 045/435] Refactor form for user working hours --- .../working_hours/availability_factor_form.rb | 63 +++++++ .../working_hours/days_and_hours_form.rb | 124 +++++++++++++ .../working_hours/form_component.html.erb | 172 +----------------- .../users/working_hours/form_component.rb | 36 ++-- .../users/working_hours/valid_from_form.rb | 50 +++++ config/locales/en.yml | 11 +- 6 files changed, 260 insertions(+), 196 deletions(-) create mode 100644 app/components/users/working_hours/availability_factor_form.rb create mode 100644 app/components/users/working_hours/days_and_hours_form.rb create mode 100644 app/components/users/working_hours/valid_from_form.rb diff --git a/app/components/users/working_hours/availability_factor_form.rb b/app/components/users/working_hours/availability_factor_form.rb new file mode 100644 index 00000000000..15fdbf85a0f --- /dev/null +++ b/app/components/users/working_hours/availability_factor_form.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Users::WorkingHours::AvailabilityFactorForm < ApplicationForm + form do |form| + form.html_content do + render(Primer::Beta::Subhead.new(spacious: true)) do |component| + component.with_heading(tag: :div) do + I18n.t("users.working_hours.form.title_availability_factor") + end + + component.with_description do + I18n.t("users.working_hours.form.availability_description") + end + end + end + + form.text_field name: :availability_factor, + label: UserWorkingHours.human_attribute_name(:availability_factor), + input_width: :large, + inputmode: "numeric", + value: model.availability_factor, + data: { + "users--working-hours-form-target": "availabilityFactorInput", + action: "input->users--working-hours-form#availabilityChanged" + }, + trailing_visual: { text: { text: "%" } } + + form.text_field name: :total_factored_hours, + label: I18n.t("users.working_hours.form.total_factored_hours"), + input_width: :large, + disabled: true, + data: { "users--working-hours-form-target": "totalFactoredHoursDisplay" }, + trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_week") } } + end +end diff --git a/app/components/users/working_hours/days_and_hours_form.rb b/app/components/users/working_hours/days_and_hours_form.rb new file mode 100644 index 00000000000..5df8418bed3 --- /dev/null +++ b/app/components/users/working_hours/days_and_hours_form.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Users::WorkingHours::DaysAndHoursForm < ApplicationForm + form do |form| + form.html_content do + render(Primer::Beta::Subhead.new(spacious: true)) do |component| + component.with_heading(tag: :div, size: :medium) do + I18n.t("users.working_hours.form.title_days_and_hours") + end + end + end + + form.group(layout: :horizontal, mb: 2) do |group| + UserWorkingHours::DAYS.each do |day| + group.hidden name: "working_hours[#{day}]", value: 0 + group.check_box name: "day_enabled_#{day}", + data: { day: day, action: "users--working-hours-form#dayToggled" }, + checked: day_enabled?(day), + label: full_day_name(day), + label_arguments: { mr: 3 } + end + end + + form.radio_button_group(name: "hours_mode", label: I18n.t("users.working_hours.form.hours_mode_label"), mb: 2) do |group| + group.radio_button( + label: I18n.t("users.working_hours.form.same_hours_mode"), + value: "same", + checked: all_same_hours?, + data: { action: "users--working-hours-form#hoursModeChanged" } + ) + group.radio_button( + label: I18n.t("users.working_hours.form.individual_hours_mode"), + value: "individual", + checked: !all_same_hours?, + data: { action: "users--working-hours-form#hoursModeChanged" } + ) + end + + form.text_field name: :shared_hours, + label: I18n.t("users.working_hours.form.work_hours"), + input_width: :large, + value: shared_hours, # TODO: format with `h` + data: { + "users--working-hours-form-target": "sharedHoursInput", + action: "input->users--working-hours-form#hoursChanged blur->users--working-hours-form#hoursFormatted" + }, + trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_day") } } + # wrapper_data_attributes: { "users--working-hours-form-target": "sameHoursSection" } + + UserWorkingHours::DAYS.each do |day| + form.text_field name: "#{day}_hours", + label: UserWorkingHours.human_attribute_name("#{day}_hours"), + value: day_hours(day), # TODO: format with `h` + input_width: :large, + data: { + "users--working-hours-form-target": "dayHoursInput", + day: day, + action: "input->users--working-hours-form#hoursChanged blur->users--working-hours-form#hoursFormatted" + }, + disabled: !day_enabled?(day) + end + + form.text_field name: :total_available_hours, + label: I18n.t("users.working_hours.form.total_available_hours"), + input_width: :large, + disabled: true, + data: { "users--working-hours-form-target": "totalAvailableHoursDisplay" }, + trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_week") } } + end + + private + + def day_enabled?(day) + model.public_send(day) > 0 + end + + def day_hours(day) + model.public_send("#{day}_hours") + end + + def all_same_hours? + enabled = UserWorkingHours::DAYS.select { |d| day_enabled?(d) } + return true if enabled.empty? + + enabled.map { |d| day_hours(d) }.uniq.one? + end + + def shared_hours + first_enabled = UserWorkingHours::DAYS.find { |d| day_enabled?(d) } + first_enabled ? day_hours(first_enabled) : Setting.hours_per_day + end + + def full_day_name(day) + I18n.t("date.day_names")[UserWorkingHours::DAY_ABBR_INDEX[day]] + end +end diff --git a/app/components/users/working_hours/form_component.html.erb b/app/components/users/working_hours/form_component.html.erb index d5a1b9460a5..2af4b2f9ebf 100644 --- a/app/components/users/working_hours/form_component.html.erb +++ b/app/components/users/working_hours/form_component.html.erb @@ -28,175 +28,11 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= component_wrapper do %> - <%= form_with( - scope: :working_hours, - url: form_url, - method: form_method, - id: "working-hours-form", - data: { - controller: "users--working-hours-form", - "users--working-hours-form-hours-mode-value": all_same_hours? ? "same" : "individual" - } - ) do |f| %> - - <% if working_hours.errors.any? %> - <%= render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) do %> - <%= working_hours.errors.full_messages.join(", ") %> - <% end %> - <% end %> - - <%# Start date %> + <%= primer_form_with(**form_options) do |f| %> <% if show_valid_from %> -
- <%= f.label :valid_from, t("users.working_hours.form.start_date"), class: "FormControl-label" %> - <%= f.date_field :valid_from, class: "FormControl-input" %> -
- - <%= render(OpenProject::Common::DividerComponent.new(mb: 4)) %> + <%= render(Users::WorkingHours::ValidFromForm.new(f)) %> <% end %> - - <%# Work days — horizontal checkboxes, rendered ABOVE the radio buttons %> -
- <%= t("users.working_hours.form.work_days") %> -
- <% UserWorkingHours::DAYS.each do |day| %> - <%# Hidden fallback — submits 0 when the number input for this day is disabled %> - - - - <% end %> -
-
- - <%# Working hours section %> - <%= render(Primer::Beta::Heading.new(tag: :h3, font_size: 4, mb: 3)) do %> - <%= t("users.working_hours.form.working_hours_label") %> - <% end %> - - <%# Hours mode selection %> - <%= render( - Primer::Alpha::RadioButtonGroup.new( - name: "hours_mode", - label: t("users.working_hours.form.hours_mode_label"), - visually_hide_label: true, - mb: 3 - ) - ) do |group| %> - <% group.radio_button( - label: t("users.working_hours.form.same_hours_mode"), - value: "same", - checked: all_same_hours?, - data: { action: "users--working-hours-form#hoursModeChanged" } - ) %> - <% group.radio_button( - label: t("users.working_hours.form.individual_hours_mode"), - value: "individual", - checked: !all_same_hours?, - data: { action: "users--working-hours-form#hoursModeChanged" } - ) %> - <% end %> - - <%# Shared hours input (visible in same hours mode) %> -
> - <%= label_tag "shared_hours", t("users.working_hours.form.work_hours"), class: "FormControl-label" %> -
- - <%= t("users.working_hours.form.per_day") %> -
-
- - <%# Individual per-day inputs (visible in individual hours mode) %> -
> - <% UserWorkingHours::DAYS.each do |day| %> -
> - <%= label_tag "working_hours_#{day}_hours", full_day_name(day), class: "FormControl-label" %> -
- > - <%= t("users.working_hours.form.per_day") %> -
-
- <% end %> -
- - <%# Total work hours (calculated, read-only) %> -
- <%= label_tag "total_work_hours", t("users.working_hours.form.total_work_hours"), class: "FormControl-label" %> -
- - <%= t("users.working_hours.form.per_week") %> -
-
- - <%= render(OpenProject::Common::DividerComponent.new(mb: 4)) %> - - <%# Availability section %> - <%= render(Primer::Beta::Heading.new(tag: :h3, font_size: 4, mb: 1)) do %> - <%= t("users.working_hours.form.availability_section") %> - <% end %> -

<%= t("users.working_hours.form.availability_description") %>

- -
- <%= f.label :availability_factor, t("users.working_hours.form.availability_factor"), class: "FormControl-label" %> -
- - % -
-
- -
- <%= label_tag "total_available_hours", t("users.working_hours.form.total_available_hours"), class: "FormControl-label" %> -
- - <%= t("users.working_hours.form.per_week") %> -
-
- + <%= render(Users::WorkingHours::DaysAndHoursForm.new(f)) %> + <%= render(Users::WorkingHours::AvailabilityFactorForm.new(f)) %> <% end %> <% end %> diff --git a/app/components/users/working_hours/form_component.rb b/app/components/users/working_hours/form_component.rb index c984f8c4258..24781e0335a 100644 --- a/app/components/users/working_hours/form_component.rb +++ b/app/components/users/working_hours/form_component.rb @@ -53,33 +53,21 @@ module Users end end + def form_options + { + model: working_hours, + url: form_url, + method: form_method, + data: { + turbo: true, + controller: "users--working-hours-form" + } + } + end + def form_method working_hours.persisted? ? :patch : :post end - - def day_enabled?(day) - working_hours.public_send(day) > 0 - end - - def day_hours(day) - working_hours.public_send("#{day}_hours") - end - - def all_same_hours? - enabled = UserWorkingHours::DAYS.select { |d| day_enabled?(d) } - return true if enabled.empty? - - enabled.map { |d| day_hours(d) }.uniq.one? - end - - def shared_hours - first_enabled = UserWorkingHours::DAYS.find { |d| day_enabled?(d) } - first_enabled ? day_hours(first_enabled) : Setting.hours_per_day - end - - def full_day_name(day) - I18n.t("date.day_names")[UserWorkingHours::DAY_ABBR_INDEX[day]] - end end end end diff --git a/app/components/users/working_hours/valid_from_form.rb b/app/components/users/working_hours/valid_from_form.rb new file mode 100644 index 00000000000..6a9d99bf215 --- /dev/null +++ b/app/components/users/working_hours/valid_from_form.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Users::WorkingHours::ValidFromForm < ApplicationForm + form do |f| + f.html_content do + render(Primer::Beta::Subhead.new) do |component| + component.with_heading(tag: :div, size: :medium) do + I18n.t("users.working_hours.form.title_future_dates") + end + end + end + + f.single_date_picker name: :valid_from, + type: "date", + required: true, + input_width: :large, + datepicker_options: { inDialog: Users::WorkingHours::DialogComponent::DIALOG_ID }, + value: model.valid_from&.iso8601, + caption: I18n.t("users.working_hours.form.start_date_caption"), + label: I18n.t("users.working_hours.form.start_date") + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 21fdd44415f..3f23afb5170 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1132,7 +1132,8 @@ en: form: title: "Plan a future work schedule" title_current: "Edit current work schedule" - start_date: "From" + start_date: "Start date" + start_date_caption: "Select the date from when the new work schedule will be effective." work_days: "Work days" working_hours_label: "Working hours" hours_mode_label: "Hours mode" @@ -1143,10 +1144,12 @@ en: per_day: "per day" per_week: "per week" total_work_hours: "Total work hours" - availability_section: "Availability" - availability_description: "Adjust the percentage of time this user can dedicate to project work." + availability_description: "The availability factor represents the actual percentage of your working time dedicated to project tasks. This accounts for meetings, emails, administrative work, and other non-project activities." availability_factor: "Availability factor" - total_available_hours: "Total available work hours" + total_factored_hours: "Total available work hours" + title_availability_factor: "Availability factor" + title_days_and_hours: "Days and hours" + title_future_dates: "Future dates" table: mobile_title: "Working schedules" start_date: "Start date" From 26293ca87f39949b57bdafd1be3c56500079293d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 2 Mar 2026 15:41:25 +0100 Subject: [PATCH 046/435] Implement form for working hours --- .../working_hours/availability_factor_form.rb | 4 +- .../working_hours/days_and_hours_form.rb | 65 ++++++++++--------- .../working_hours/dialog_component.html.erb | 4 +- .../users/working_hours/form_component.rb | 12 +++- .../working_hours/schedule_table_component.rb | 1 + .../user_working_hours/base_contract.rb | 6 +- .../users/working_hours_controller.rb | 20 +++--- app/models/user_working_hours.rb | 3 +- config/locales/en.yml | 2 +- .../users/working-hours-form.controller.ts | 57 +++++++++++----- spec/models/user_working_hours_spec.rb | 26 ++++++++ 11 files changed, 130 insertions(+), 70 deletions(-) diff --git a/app/components/users/working_hours/availability_factor_form.rb b/app/components/users/working_hours/availability_factor_form.rb index 15fdbf85a0f..5cb8a0d7e43 100644 --- a/app/components/users/working_hours/availability_factor_form.rb +++ b/app/components/users/working_hours/availability_factor_form.rb @@ -54,10 +54,10 @@ class Users::WorkingHours::AvailabilityFactorForm < ApplicationForm trailing_visual: { text: { text: "%" } } form.text_field name: :total_factored_hours, - label: I18n.t("users.working_hours.form.total_factored_hours"), + label: I18n.t("users.working_hours.form.total_available_hours"), input_width: :large, disabled: true, - data: { "users--working-hours-form-target": "totalFactoredHoursDisplay" }, + data: { "users--working-hours-form-target": "totalAvailableHoursDisplay" }, trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_week") } } end end diff --git a/app/components/users/working_hours/days_and_hours_form.rb b/app/components/users/working_hours/days_and_hours_form.rb index 5df8418bed3..6fede112891 100644 --- a/app/components/users/working_hours/days_and_hours_form.rb +++ b/app/components/users/working_hours/days_and_hours_form.rb @@ -40,9 +40,13 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm form.group(layout: :horizontal, mb: 2) do |group| UserWorkingHours::DAYS.each do |day| - group.hidden name: "working_hours[#{day}]", value: 0 + group.hidden name: "#{day}_hours", value: 0 group.check_box name: "day_enabled_#{day}", - data: { day: day, action: "users--working-hours-form#dayToggled" }, + data: { + "users--working-hours-form-target": "dayCheckbox", + day: day, + action: "users--working-hours-form#dayToggled" + }, checked: day_enabled?(day), label: full_day_name(day), label_arguments: { mr: 3 } @@ -64,35 +68,38 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm ) end - form.text_field name: :shared_hours, - label: I18n.t("users.working_hours.form.work_hours"), - input_width: :large, - value: shared_hours, # TODO: format with `h` - data: { - "users--working-hours-form-target": "sharedHoursInput", - action: "input->users--working-hours-form#hoursChanged blur->users--working-hours-form#hoursFormatted" - }, - trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_day") } } - # wrapper_data_attributes: { "users--working-hours-form-target": "sameHoursSection" } - - UserWorkingHours::DAYS.each do |day| - form.text_field name: "#{day}_hours", - label: UserWorkingHours.human_attribute_name("#{day}_hours"), - value: day_hours(day), # TODO: format with `h` - input_width: :large, - data: { - "users--working-hours-form-target": "dayHoursInput", - day: day, - action: "input->users--working-hours-form#hoursChanged blur->users--working-hours-form#hoursFormatted" - }, - disabled: !day_enabled?(day) + form.group(data: { "users--working-hours-form-target": "sameHoursSection" }) do |group| + group.text_field name: :shared_hours, + label: I18n.t("users.working_hours.form.work_hours"), + input_width: :large, + value: shared_hours, + data: { + "users--working-hours-form-target": "sharedHoursInput", + action: "input->users--working-hours-form#hoursChanged blur->users--working-hours-form#hoursFormatted" + }, + trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_day") } } end - form.text_field name: :total_available_hours, - label: I18n.t("users.working_hours.form.total_available_hours"), + form.group(data: { "users--working-hours-form-target": "individualSection" }) do |group| + UserWorkingHours::DAYS.each do |day| + group.text_field name: "#{day}_hours", + label: UserWorkingHours.human_attribute_name("#{day}_hours"), + value: day_hours(day), + input_width: :large, + data: { + "users--working-hours-form-target": "dayHoursInput", + day: day, + action: "input->users--working-hours-form#hoursChanged blur->users--working-hours-form#hoursFormatted" + }, + disabled: !day_enabled?(day) + end + end + + form.text_field name: :total_work_hours, + label: I18n.t("users.working_hours.form.total_work_hours"), input_width: :large, disabled: true, - data: { "users--working-hours-form-target": "totalAvailableHoursDisplay" }, + data: { "users--working-hours-form-target": "totalWorkHoursDisplay" }, trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_week") } } end @@ -103,7 +110,7 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm end def day_hours(day) - model.public_send("#{day}_hours") + "#{model.public_send("#{day}_hours").round(2)}h" end def all_same_hours? @@ -115,7 +122,7 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm def shared_hours first_enabled = UserWorkingHours::DAYS.find { |d| day_enabled?(d) } - first_enabled ? day_hours(first_enabled) : Setting.hours_per_day + first_enabled ? day_hours(first_enabled) : "#{Setting.hours_per_day.round(2)}h" end def full_day_name(day) diff --git a/app/components/users/working_hours/dialog_component.html.erb b/app/components/users/working_hours/dialog_component.html.erb index af38effb877..4c8b07ad5cd 100644 --- a/app/components/users/working_hours/dialog_component.html.erb +++ b/app/components/users/working_hours/dialog_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :xlarge)) do |dialog| %> +<%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, position: :right, size: :xlarge)) do |dialog| %> <% dialog.with_header(variant: :large) %> <% dialog.with_body do %> <%= render(Users::WorkingHours::FormComponent.new(user:, working_hours:, show_valid_from:)) %> @@ -37,7 +37,7 @@ See COPYRIGHT and LICENSE files for more details. <% footer.with_component(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) do %> <%= t(:button_cancel) %> <% end %> - <% footer.with_component(Primer::Beta::Button.new(scheme: :primary, form: "working-hours-form", type: :submit)) do %> + <% footer.with_component(Primer::Beta::Button.new(scheme: :primary, form: Users::WorkingHours::FormComponent::FORM_ID, type: :submit)) do %> <%= working_hours.persisted? ? t(:button_save) : t(:button_create) %> <% end %> <% end %> diff --git a/app/components/users/working_hours/form_component.rb b/app/components/users/working_hours/form_component.rb index 24781e0335a..7ce319c18a5 100644 --- a/app/components/users/working_hours/form_component.rb +++ b/app/components/users/working_hours/form_component.rb @@ -34,6 +34,8 @@ module Users include OpTurbo::Streamable include OpPrimer::ComponentHelpers + FORM_ID = "working-hours-form" + attr_reader :user, :working_hours, :show_valid_from def initialize(user:, working_hours:, show_valid_from: true, **) @@ -58,9 +60,13 @@ module Users model: working_hours, url: form_url, method: form_method, - data: { - turbo: true, - controller: "users--working-hours-form" + + html: { + id: FORM_ID, + data: { + turbo: true, + controller: "users--working-hours-form" + } } } end diff --git a/app/components/users/working_hours/schedule_table_component.rb b/app/components/users/working_hours/schedule_table_component.rb index 7ca2116c843..b967631c0ba 100644 --- a/app/components/users/working_hours/schedule_table_component.rb +++ b/app/components/users/working_hours/schedule_table_component.rb @@ -32,6 +32,7 @@ module Users module WorkingHours class ScheduleTableComponent < OpPrimer::BorderBoxTableComponent columns :start_date, :work_days, :work_hours, :availability_factor, :effective_work_hours + main_column :work_days attr_reader :variant, :user diff --git a/app/contracts/user_working_hours/base_contract.rb b/app/contracts/user_working_hours/base_contract.rb index 5e439187a3e..a67e79e72bb 100644 --- a/app/contracts/user_working_hours/base_contract.rb +++ b/app/contracts/user_working_hours/base_contract.rb @@ -29,11 +29,9 @@ #++ class UserWorkingHours::BaseContract < ModelContract - DAYS = %i[monday tuesday wednesday thursday friday saturday sunday].freeze - attribute :user_id attribute :valid_from - DAYS.each { |day| attribute :"#{day}_hours" } + ::UserWorkingHours::DAYS.each { |day| attribute :"#{day}_hours" } attribute :availability_factor validate :validate_manage_permission @@ -64,7 +62,7 @@ class UserWorkingHours::BaseContract < ModelContract # map those raw column names back to their hours equivalents so the writable # attribute check passes correctly. def changed_by_user - day_names = DAYS.map(&:to_s) + day_names = ::UserWorkingHours::DAYS.map(&:to_s) super.map { |attr| day_names.include?(attr) ? "#{attr}_hours" : attr } end end diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb index ca7da6e1833..528e8e669e8 100644 --- a/app/controllers/users/working_hours_controller.rb +++ b/app/controllers/users/working_hours_controller.rb @@ -163,16 +163,16 @@ class Users::WorkingHoursController < ApplicationController def working_hours_params params.expect( - working_hours: %i[valid_from - monday_hours - tuesday_hours - wednesday_hours - thursday_hours - friday_hours - saturday_hours - sunday_hours - availability_factor] - ) + user_working_hours: %i[valid_from + monday_hours + tuesday_hours + wednesday_hours + thursday_hours + friday_hours + saturday_hours + sunday_hours + availability_factor] + ).tap { Rails.logger.debug(it.to_h) } end def duplicate_current_working_hours(user) diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb index 9c7f890ecbd..1a8a11681d1 100644 --- a/app/models/user_working_hours.rb +++ b/app/models/user_working_hours.rb @@ -71,7 +71,8 @@ class UserWorkingHours < ApplicationRecord (public_send(day) / 60.0).round(2) end - define_method("#{day}_hours=") do |hours| + define_method("#{day}_hours=") do |value| + hours = value.is_a?(String) ? (value.to_hours || value) : value public_send("#{day}=", (hours.to_f * 60).round) end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3f23afb5170..c14974050cf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1146,7 +1146,7 @@ en: total_work_hours: "Total work hours" availability_description: "The availability factor represents the actual percentage of your working time dedicated to project tasks. This accounts for meetings, emails, administrative work, and other non-project activities." availability_factor: "Availability factor" - total_factored_hours: "Total available work hours" + total_available_hours: "Total available work hours" title_availability_factor: "Availability factor" title_days_and_hours: "Days and hours" title_future_dates: "Future dates" diff --git a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts index 493713e959d..06ec564cdfa 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts @@ -29,7 +29,7 @@ */ import { Controller } from '@hotwired/stimulus'; -import { durationStringToSeconds } from 'core-stimulus/helpers/chronic-duration-helper'; +import { durationStringToSeconds, formattedHour } from 'core-stimulus/helpers/chronic-duration-helper'; export default class WorkingHoursFormController extends Controller { static targets = [ @@ -44,27 +44,25 @@ export default class WorkingHoursFormController extends Controller { 'totalAvailableHoursDisplay', ]; - static values = { - hoursMode: { type: String, default: 'same' }, - }; - declare readonly sameHoursSectionTarget:HTMLElement; declare readonly individualSectionTarget:HTMLElement; declare readonly sharedHoursInputTarget:HTMLInputElement; declare readonly dayCheckboxTargets:HTMLInputElement[]; - declare readonly dayHoursSectionTargets:HTMLElement[]; declare readonly dayHoursInputTargets:HTMLInputElement[]; declare readonly totalWorkHoursDisplayTarget:HTMLInputElement; declare readonly availabilityFactorInputTarget:HTMLInputElement; declare readonly totalAvailableHoursDisplayTarget:HTMLInputElement; - declare hoursModeValue:string; + + private hoursModeValue:'same'|'individual' = 'same'; connect() { + this.detectHoursMode(); + this.hideDisabledDayHours(); this.recalculate(); } hoursModeChanged(event:Event) { - this.hoursModeValue = (event.target as HTMLInputElement).value; + this.hoursModeValue = (event.target as HTMLInputElement).value as 'same' | 'individual'; this.updateDisplayMode(); if (this.hoursModeValue === 'same') { this.syncSameHoursToAllDays(); @@ -76,13 +74,12 @@ export default class WorkingHoursFormController extends Controller { const checkbox = event.target as HTMLInputElement; const day = checkbox.dataset.day!; const hoursInput = this.dayHoursInputForDay(day); - const hoursSection = this.dayHoursSectionForDay(day); + if (hoursInput) { hoursInput.disabled = !checkbox.checked; + this.toggleDayHoursWrapperVisbility(day, checkbox.checked); } - if (hoursSection) { - hoursSection.hidden = !checkbox.checked; - } + if (this.hoursModeValue === 'same') { this.syncSameHoursToAllDays(); } @@ -96,17 +93,18 @@ export default class WorkingHoursFormController extends Controller { this.recalculate(); } + // Triggered on blur: parse the entered duration string and reformat as a plain decimal hours value. // This lets users type "4:30", "4h30min", "4,5", etc. — same logic as the time entry form. hoursFormatted(event:Event) { const input = event.target as HTMLInputElement; const seconds = durationStringToSeconds(input.value); - const hours = Math.round(seconds / 3600 * 100) / 100; - input.value = hours > 0 ? String(hours) : ''; + input.value = formattedHour(seconds); if (this.hoursModeValue === 'same') { this.syncSameHoursToAllDays(); } + this.recalculate(); } @@ -114,6 +112,22 @@ export default class WorkingHoursFormController extends Controller { this.recalculate(); } + private detectHoursMode() { + const checked = document.querySelector('input[name="user_working_hours[hours_mode]"]:checked'); + if (checked) { + this.hoursModeValue = checked.value as 'same' | 'individual'; + } + + this.updateDisplayMode(); + } + + private hideDisabledDayHours() { + this.dayCheckboxTargets.forEach((checkbox) => { + const day = checkbox.dataset.day!; + this.toggleDayHoursWrapperVisbility(day, checkbox.checked); + }); + } + private updateDisplayMode() { const isSame = this.hoursModeValue === 'same'; this.sameHoursSectionTarget.hidden = !isSame; @@ -122,11 +136,11 @@ export default class WorkingHoursFormController extends Controller { private syncSameHoursToAllDays() { const seconds = durationStringToSeconds(this.sharedHoursInputTarget.value); - const hours = Math.round(seconds / 3600 * 100) / 100; + this.dayHoursInputTargets.forEach((input) => { const checkbox = this.dayCheckboxForDay(input.dataset.day!); if (checkbox?.checked) { - input.value = String(hours); + input.value = formattedHour(seconds); } }); } @@ -163,8 +177,15 @@ export default class WorkingHoursFormController extends Controller { return this.dayHoursInputTargets.find((el) => el.dataset.day === day); } - private dayHoursSectionForDay(day:string):HTMLElement|undefined { - return this.dayHoursSectionTargets.find((el) => el.dataset.day === day); + private toggleDayHoursWrapperVisbility(day:string, visible:boolean ) { + const input = this.dayHoursInputForDay(day); + + if (input) { + const wrapper = input.closest('primer-text-field'); + if (wrapper) { + wrapper.classList.toggle('d-none', !visible); + } + } } private dayCheckboxForDay(day:string):HTMLInputElement|undefined { diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb index 863e3d922c2..75cc783ce46 100644 --- a/spec/models/user_working_hours_spec.rb +++ b/spec/models/user_working_hours_spec.rb @@ -81,6 +81,32 @@ RSpec.describe UserWorkingHours do end end + # The following tests cover string parsing via `to_hours` for the `monday_hours=` setter. + # The same parsing logic applies to all `{day}_hours=` setters since they are generated identically. + describe "#monday_hours= string parsing" do + subject(:working_hours) { build(:user_working_hours) } + + { + "8" => 480, + "7.5" => 450, + "7,5" => 450, + "8h" => 480, + "7.5h" => 450, + "7,5h" => 450, + "7:30" => 450, + "2h30" => 150, + "2h30m" => 150, + "2h 30m" => 150, + "2h" => 120, + "30m" => 30 + }.each do |input, expected_minutes| + it "parses #{input.inspect} to #{expected_minutes} minutes" do + working_hours.monday_hours = input + expect(working_hours.monday).to eq(expected_minutes) + end + end + end + it "returns 8.0 hours for a full work day of 480 minutes" do expect(working_hours.monday_hours).to eq(8.0) end From 1ba9997bfc07df369392d9f630b589c2653e3c03 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 2 Mar 2026 17:24:28 +0100 Subject: [PATCH 047/435] Some more little refactorings --- .../working_hours/availability_factor_form.rb | 1 + config/locales/en.yml | 1 + .../users/working-hours-form.controller.ts | 22 +++++++------------ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app/components/users/working_hours/availability_factor_form.rb b/app/components/users/working_hours/availability_factor_form.rb index 5cb8a0d7e43..d44066e773b 100644 --- a/app/components/users/working_hours/availability_factor_form.rb +++ b/app/components/users/working_hours/availability_factor_form.rb @@ -44,6 +44,7 @@ class Users::WorkingHours::AvailabilityFactorForm < ApplicationForm form.text_field name: :availability_factor, label: UserWorkingHours.human_attribute_name(:availability_factor), + caption: I18n.t("users.working_hours.form.availability_factor_caption"), input_width: :large, inputmode: "numeric", value: model.availability_factor, diff --git a/config/locales/en.yml b/config/locales/en.yml index c14974050cf..263c2a7feb1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1146,6 +1146,7 @@ en: total_work_hours: "Total work hours" availability_description: "The availability factor represents the actual percentage of your working time dedicated to project tasks. This accounts for meetings, emails, administrative work, and other non-project activities." availability_factor: "Availability factor" + availability_factor_caption: "Define the percentage of your working time dedicated to project work." total_available_hours: "Total available work hours" title_availability_factor: "Availability factor" title_days_and_hours: "Days and hours" diff --git a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts index 06ec564cdfa..d87bb006486 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/working-hours-form.controller.ts @@ -37,7 +37,6 @@ export default class WorkingHoursFormController extends Controller { 'individualSection', 'sharedHoursInput', 'dayCheckbox', - 'dayHoursSection', 'dayHoursInput', 'totalWorkHoursDisplay', 'availabilityFactorInput', @@ -77,7 +76,7 @@ export default class WorkingHoursFormController extends Controller { if (hoursInput) { hoursInput.disabled = !checkbox.checked; - this.toggleDayHoursWrapperVisbility(day, checkbox.checked); + this.toggleDayHoursWrapperVisibility(day, checkbox.checked); } if (this.hoursModeValue === 'same') { @@ -113,7 +112,7 @@ export default class WorkingHoursFormController extends Controller { } private detectHoursMode() { - const checked = document.querySelector('input[name="user_working_hours[hours_mode]"]:checked'); + const checked = this.element.querySelector('input[name="user_working_hours[hours_mode]"]:checked'); if (checked) { this.hoursModeValue = checked.value as 'same' | 'individual'; } @@ -124,7 +123,7 @@ export default class WorkingHoursFormController extends Controller { private hideDisabledDayHours() { this.dayCheckboxTargets.forEach((checkbox) => { const day = checkbox.dataset.day!; - this.toggleDayHoursWrapperVisbility(day, checkbox.checked); + this.toggleDayHoursWrapperVisibility(day, checkbox.checked); }); } @@ -151,33 +150,28 @@ export default class WorkingHoursFormController extends Controller { if (this.hoursModeValue === 'same') { const seconds = durationStringToSeconds(this.sharedHoursInputTarget.value); const checkedCount = this.dayCheckboxTargets.filter((cb) => cb.checked).length; - totalHours = (seconds / 3600) * checkedCount; + totalHours = seconds * checkedCount; } else { this.dayHoursInputTargets.forEach((input) => { const checkbox = this.dayCheckboxForDay(input.dataset.day!); if (checkbox?.checked) { - totalHours += durationStringToSeconds(input.value) / 3600; + totalHours += durationStringToSeconds(input.value); } }); } - this.totalWorkHoursDisplayTarget.value = this.formatHours(totalHours); + this.totalWorkHoursDisplayTarget.value = formattedHour(totalHours); const factor = parseFloat(this.availabilityFactorInputTarget.value); const available = totalHours * (isNaN(factor) ? 100 : factor) / 100; - this.totalAvailableHoursDisplayTarget.value = this.formatHours(available); - } - - private formatHours(hours:number):string { - const rounded = Math.round(hours * 100) / 100; - return `${rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(2).replace(/0+$/, '')}h`; + this.totalAvailableHoursDisplayTarget.value = formattedHour(available); } private dayHoursInputForDay(day:string):HTMLInputElement|undefined { return this.dayHoursInputTargets.find((el) => el.dataset.day === day); } - private toggleDayHoursWrapperVisbility(day:string, visible:boolean ) { + private toggleDayHoursWrapperVisibility(day:string, visible:boolean) { const input = this.dayHoursInputForDay(day); if (input) { From 1146edff0e993bacd92346405518c4dfdb128c1b Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 2 Mar 2026 18:26:36 +0100 Subject: [PATCH 048/435] Drive-By-Refactoring: Fix start of week for my time tracking --- .../costs/app/components/my/time_tracking/calendar_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/costs/app/components/my/time_tracking/calendar_component.rb b/modules/costs/app/components/my/time_tracking/calendar_component.rb index 2645208a2b3..8fe15677fec 100644 --- a/modules/costs/app/components/my/time_tracking/calendar_component.rb +++ b/modules/costs/app/components/my/time_tracking/calendar_component.rb @@ -52,7 +52,7 @@ module My "my--time-tracking-allow-times-value" => TimeEntry.can_track_start_and_end_time?, "my--time-tracking-force-times-value" => TimeEntry.must_track_start_and_end_time?, "my--time-tracking-locale-value" => I18n.locale, - "my--time-tracking-start-of-week-value" => Setting.start_of_week, + "my--time-tracking-start-of-week-value" => (Setting.start_of_week || 1) % 7, "my--time-tracking-working-days-value" => working_days, "my--time-tracking-time-zone-value" => User.current.time_zone.name } From 727cb945e846ed86544092d000610232de9a2ebc Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 2 Mar 2026 19:38:11 +0100 Subject: [PATCH 049/435] Refactor UserNonWorkingDay into a model that covers date ranges --- .../working_hours/days_and_hours_form.rb | 15 +- .../base_contract.rb | 7 +- .../create_contract.rb | 15 +- .../delete_contract.rb | 2 +- app/controllers/my_controller.rb | 10 +- ...ler.rb => non_working_times_controller.rb} | 26 +- app/models/user.rb | 12 +- app/models/user_non_working_day.rb | 47 ---- app/models/user_non_working_time.rb | 92 ++++++ .../create_service.rb | 2 +- .../delete_service.rb | 2 +- .../set_attributes_service.rb | 2 +- app/views/my/non_working_days.html.erb | 3 - app/views/my/non_working_times.html.erb | 11 + .../_list.html.erb | 11 +- config/locales/en.yml | 18 +- config/routes.rb | 4 +- ...0260219152519_add_user_non_working_days.rb | 15 - ...260219152519_add_user_non_working_times.rb | 27 ++ ...ser_non_working_time_collection_model.yml} | 16 +- ...el.yml => user_non_working_time_model.yml} | 10 +- docs/api/apiv3/openapi-spec.yml | 16 +- ...ng_days.yml => user_non_working_times.yml} | 12 +- ...te.yml => user_non_working_times_date.yml} | 4 +- docs/api/apiv3/tags/user_working_times.yml | 12 +- .../non_working_times_by_user_api.rb} | 28 +- ...on_working_time_collection_representer.rb} | 4 +- ...r_non_working_time_payload_representer.rb} | 4 +- .../user_non_working_time_representer.rb} | 11 +- lib/api/v3/users/users_api.rb | 2 +- lib/api/v3/utilities/path_helper.rb | 8 +- lib/open_project/ui/extensible_tabs.rb | 6 +- .../create_contract_spec.rb | 47 +--- .../delete_contract_spec.rb | 4 +- ...ry.rb => user_non_working_time_factory.rb} | 9 +- spec/models/user_non_working_day_spec.rb | 116 -------- spec/models/user_non_working_time_spec.rb | 261 ++++++++++++++++++ .../non_working_times_by_user_api_spec.rb} | 92 +++--- .../create_service_spec.rb | 41 +-- .../delete_service_spec.rb | 14 +- 40 files changed, 602 insertions(+), 436 deletions(-) rename app/contracts/{user_non_working_days => user_non_working_times}/base_contract.rb (93%) rename app/contracts/{user_non_working_days => user_non_working_times}/create_contract.rb (75%) rename app/contracts/{user_non_working_days => user_non_working_times}/delete_contract.rb (98%) rename app/controllers/users/{non_working_days_controller.rb => non_working_times_controller.rb} (76%) delete mode 100644 app/models/user_non_working_day.rb create mode 100644 app/models/user_non_working_time.rb rename app/services/{user_non_working_days => user_non_working_times}/create_service.rb (97%) rename app/services/{user_non_working_days => user_non_working_times}/delete_service.rb (97%) rename app/services/{user_non_working_days => user_non_working_times}/set_attributes_service.rb (97%) delete mode 100644 app/views/my/non_working_days.html.erb create mode 100644 app/views/my/non_working_times.html.erb rename app/views/users/{non_working_days => non_working_times}/_list.html.erb (69%) delete mode 100644 db/migrate/20260219152519_add_user_non_working_days.rb create mode 100644 db/migrate/20260219152519_add_user_non_working_times.rb rename docs/api/apiv3/components/schemas/{user_non_working_day_collection_model.yml => user_non_working_time_collection_model.yml} (72%) rename docs/api/apiv3/components/schemas/{user_non_working_day_model.yml => user_non_working_time_model.yml} (82%) rename docs/api/apiv3/paths/{user_non_working_days.yml => user_non_working_times.yml} (93%) rename docs/api/apiv3/paths/{user_non_working_days_date.yml => user_non_working_times_date.yml} (96%) rename lib/api/v3/{user_non_working_days/non_working_days_by_user_api.rb => user_non_working_times/non_working_times_by_user_api.rb} (72%) rename lib/api/v3/{user_non_working_days/user_non_working_day_collection_representer.rb => user_non_working_times/user_non_working_time_collection_representer.rb} (90%) rename lib/api/v3/{user_non_working_days/user_non_working_day_payload_representer.rb => user_non_working_times/user_non_working_time_payload_representer.rb} (91%) rename lib/api/v3/{user_non_working_days/user_non_working_day_representer.rb => user_non_working_times/user_non_working_time_representer.rb} (85%) rename spec/contracts/{user_non_working_days => user_non_working_times}/create_contract_spec.rb (62%) rename spec/contracts/{user_non_working_days => user_non_working_times}/delete_contract_spec.rb (95%) rename spec/factories/{user_non_working_day_factory.rb => user_non_working_time_factory.rb} (85%) delete mode 100644 spec/models/user_non_working_day_spec.rb create mode 100644 spec/models/user_non_working_time_spec.rb rename spec/requests/api/v3/{user_non_working_days/non_working_days_by_user_api_spec.rb => user_non_working_times/non_working_times_by_user_api_spec.rb} (64%) rename spec/services/{user_non_working_days => user_non_working_times}/create_service_spec.rb (68%) rename spec/services/{user_non_working_days => user_non_working_times}/delete_service_spec.rb (83%) diff --git a/app/components/users/working_hours/days_and_hours_form.rb b/app/components/users/working_hours/days_and_hours_form.rb index 6fede112891..2bb0520c9ce 100644 --- a/app/components/users/working_hours/days_and_hours_form.rb +++ b/app/components/users/working_hours/days_and_hours_form.rb @@ -39,7 +39,7 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm end form.group(layout: :horizontal, mb: 2) do |group| - UserWorkingHours::DAYS.each do |day| + ordered_days.each do |day| group.hidden name: "#{day}_hours", value: 0 group.check_box name: "day_enabled_#{day}", data: { @@ -81,7 +81,7 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm end form.group(data: { "users--working-hours-form-target": "individualSection" }) do |group| - UserWorkingHours::DAYS.each do |day| + ordered_days.each do |day| group.text_field name: "#{day}_hours", label: UserWorkingHours.human_attribute_name("#{day}_hours"), value: day_hours(day), @@ -105,6 +105,17 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm private + def ordered_days + # DAYS = [monday(0), tuesday(1), ..., saturday(5), sunday(6)] + # Setting.start_of_week: 1=Monday, 6=Saturday, 7=Sunday, nil=locale default (treat as Monday) + start_index = case Setting.start_of_week + when 6 then UserWorkingHours::DAYS.index(:saturday) + when 7 then UserWorkingHours::DAYS.index(:sunday) + else 0 # Monday + end + UserWorkingHours::DAYS.rotate(start_index) + end + def day_enabled?(day) model.public_send(day) > 0 end diff --git a/app/contracts/user_non_working_days/base_contract.rb b/app/contracts/user_non_working_times/base_contract.rb similarity index 93% rename from app/contracts/user_non_working_days/base_contract.rb rename to app/contracts/user_non_working_times/base_contract.rb index d5fadac4c5a..56d771b1635 100644 --- a/app/contracts/user_non_working_days/base_contract.rb +++ b/app/contracts/user_non_working_times/base_contract.rb @@ -28,14 +28,15 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserNonWorkingDays +module UserNonWorkingTimes class BaseContract < ::ModelContract attribute :user_id - attribute :date + attribute :start_date + attribute :end_date validate :validate_manage_permission - def self.model = ::UserNonWorkingDay + def self.model = ::UserNonWorkingTime private diff --git a/app/contracts/user_non_working_days/create_contract.rb b/app/contracts/user_non_working_times/create_contract.rb similarity index 75% rename from app/contracts/user_non_working_days/create_contract.rb rename to app/contracts/user_non_working_times/create_contract.rb index 74ea7bba460..b4d3217c31a 100644 --- a/app/contracts/user_non_working_days/create_contract.rb +++ b/app/contracts/user_non_working_times/create_contract.rb @@ -28,20 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserNonWorkingDays +module UserNonWorkingTimes class CreateContract < BaseContract - validate :validate_no_system_wide_conflict - - private - - # A user-specific non-working day cannot be added when a system-wide - # non-working day already exists for the same date. - def validate_no_system_wide_conflict - return if model.date.blank? - - if NonWorkingDay.exists?(date: model.date) - errors.add :date, :system_wide_non_working_day_exists - end - end end end diff --git a/app/contracts/user_non_working_days/delete_contract.rb b/app/contracts/user_non_working_times/delete_contract.rb similarity index 98% rename from app/contracts/user_non_working_days/delete_contract.rb rename to app/contracts/user_non_working_times/delete_contract.rb index 53643596e06..dec25b29076 100644 --- a/app/contracts/user_non_working_days/delete_contract.rb +++ b/app/contracts/user_non_working_times/delete_contract.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserNonWorkingDays +module UserNonWorkingTimes class DeleteContract < ::DeleteContract delete_permission -> { user.allowed_globally?(:manage_working_times) || diff --git a/app/controllers/my_controller.rb b/app/controllers/my_controller.rb index ecf65b64322..3d3e0f51a32 100644 --- a/app/controllers/my_controller.rb +++ b/app/controllers/my_controller.rb @@ -51,7 +51,7 @@ class MyController < ApplicationController :password_confirmation_dialog, :notifications, :reminders, - :non_working_days, + :non_working_times, :working_hours menu_item :account, only: [:account] @@ -60,7 +60,7 @@ class MyController < ApplicationController menu_item :password, only: [:password] menu_item :notifications, only: [:notifications] menu_item :reminders, only: [:reminders] - menu_item :working_hours, only: %i[working_hours non_working_days] + menu_item :working_hours, only: %i[working_hours non_working_times] def account; end @@ -113,11 +113,11 @@ class MyController < ApplicationController end end - def non_working_days + def non_working_times render_403 unless OpenProject::FeatureDecisions.user_working_times_active? - year = (params[:year].presence || Date.current.year).to_i - @non_working_days = @user.non_working_day_entities_for_year(year) + @year = (params[:year].presence || Date.current.year).to_i + @non_working_times = @user.non_working_time_entities_for_year(@year) end private diff --git a/app/controllers/users/non_working_days_controller.rb b/app/controllers/users/non_working_times_controller.rb similarity index 76% rename from app/controllers/users/non_working_days_controller.rb rename to app/controllers/users/non_working_times_controller.rb index b0e8dcc2bdf..8da18c58ba2 100644 --- a/app/controllers/users/non_working_days_controller.rb +++ b/app/controllers/users/non_working_times_controller.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Users::NonWorkingDaysController < ApplicationController +class Users::NonWorkingTimesController < ApplicationController include WorkingTimesAuthorization layout "admin" @@ -39,19 +39,19 @@ class Users::NonWorkingDaysController < ApplicationController before_action :find_user before_action :authorize_manage_working_times - before_action :find_non_working_day, only: %i[destroy] + before_action :find_non_working_time, only: %i[destroy] def index @year = (params[:year].presence || Date.current.year).to_i - @non_working_days = @user.non_working_day_entities_for_year(@year) + @non_working_times = @user.non_working_time_entities_for_year(@year) render "users/edit" end def create - call = UserNonWorkingDays::CreateService + call = UserNonWorkingTimes::CreateService .new(user: current_user) - .call(**non_working_day_params, user: @user) + .call(**non_working_time_params, user: @user) if call.success? flash[:notice] = I18n.t(:notice_successful_create) @@ -59,12 +59,12 @@ class Users::NonWorkingDaysController < ApplicationController flash[:error] = call.errors.full_messages.join(", ") end - redirect_to user_non_working_days_path(@user) + redirect_to user_non_working_times_path(@user) end def destroy - call = UserNonWorkingDays::DeleteService - .new(model: @user_non_working_day, user: current_user) + call = UserNonWorkingTimes::DeleteService + .new(model: @user_non_working_time, user: current_user) .call if call.success? @@ -73,7 +73,7 @@ class Users::NonWorkingDaysController < ApplicationController flash[:error] = call.errors.full_messages.join(", ") end - redirect_to user_non_working_days_path(@user) + redirect_to user_non_working_times_path(@user) end private @@ -84,13 +84,13 @@ class Users::NonWorkingDaysController < ApplicationController render_404 end - def find_non_working_day - @user_non_working_day = @user.non_working_days.find(params[:id]) + def find_non_working_time + @user_non_working_time = @user.non_working_times.find(params[:id]) rescue ActiveRecord::RecordNotFound render_404 end - def non_working_day_params - params.expect(non_working_day: [:date]) + def non_working_time_params + params.expect(non_working_time: [:date]) end end diff --git a/app/models/user.rb b/app/models/user.rb index 7ae30b7270b..b9da75b0693 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -63,9 +63,9 @@ class User < Principal has_many :working_hours, class_name: "UserWorkingHours", dependent: :destroy, inverse_of: :user - has_many :non_working_days, class_name: "UserNonWorkingDay", - dependent: :destroy, - inverse_of: :user + has_many :non_working_times, class_name: "UserNonWorkingTime", + dependent: :destroy, + inverse_of: :user # The user might have one invitation token has_one :invitation_token, class_name: "::Token::Invitation", dependent: :destroy @@ -680,15 +680,15 @@ class User < Principal include Scimitar::Resources::Mixin - def non_working_day_entities_for_year(year) + def non_working_time_entities_for_year(year) system_days = NonWorkingDay.for_year(year).to_a - user_days = non_working_days.for_year(year).to_a + user_days = non_working_times.for_year(year).to_a system_day_dates = system_days.to_set(&:date) system_days + user_days.reject { |d| system_day_dates.include?(d.date) } end def non_working_days_for_year(year) - non_working_day_entities_for_year(year).map(&:date) + non_working_time_entities_for_year(year).map(&:date) end protected diff --git a/app/models/user_non_working_day.rb b/app/models/user_non_working_day.rb deleted file mode 100644 index 1ec9def46af..00000000000 --- a/app/models/user_non_working_day.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -class UserNonWorkingDay < ApplicationRecord - belongs_to :user, inverse_of: :non_working_days - - validates :date, presence: true, uniqueness: { scope: :user_id } - - scope :for_year, ->(year) { where(date: Date.new(year, 1, 1)..Date.new(year, 12, 31)) } - - scope :for_user, ->(user) { where(user:) } - - scope :visible, ->(user = User.current) do - if user.allowed_globally?(:manage_working_times) - all - else - where(user:) - end - end -end diff --git a/app/models/user_non_working_time.rb b/app/models/user_non_working_time.rb new file mode 100644 index 00000000000..076d4e32b85 --- /dev/null +++ b/app/models/user_non_working_time.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class UserNonWorkingTime < ApplicationRecord + belongs_to :user, inverse_of: :non_working_times + + validates :start_date, :end_date, presence: true + validate :end_date_not_before_start_date + validate :no_overlapping_ranges + + # Returns records whose range overlaps with the given year. + scope :for_year, ->(year) { + where("daterange(start_date, end_date, '[]') && daterange(?, ?, '[]')", + Date.new(year, 1, 1), Date.new(year, 12, 31)) + } + + scope :for_user, ->(user) { where(user:) } + + scope :visible, ->(user = User.current) do + if user.allowed_globally?(:manage_working_times) + all + else + where(user:) + end + end + + def days + start_date..end_date + end + + def calendar_days_count + (end_date - start_date).to_i + 1 + end + + def working_days + working_wdays = Setting.working_days.map { |d| d % 7 } + system_wide = NonWorkingDay.where(date: days).pluck(:date).to_set + days.select { |date| working_wdays.include?(date.wday) && system_wide.exclude?(date) } + end + + delegate :count, to: :working_days, prefix: true + + private + + def end_date_not_before_start_date + return unless start_date.present? && end_date.present? + + errors.add(:end_date, :not_before_start_date) if end_date < start_date + end + + def no_overlapping_ranges + return unless start_date.present? && end_date.present? && user_id.present? + + errors.add(:start_date, :overlapping_range) if overlapping_range_exists? + end + + def overlapping_range_exists? + scope = self.class + .where(user_id:) + .where("daterange(start_date, end_date, '[]') && daterange(?, ?, '[]')", + start_date, end_date) + scope = scope.where.not(id:) if persisted? + scope.exists? + end +end diff --git a/app/services/user_non_working_days/create_service.rb b/app/services/user_non_working_times/create_service.rb similarity index 97% rename from app/services/user_non_working_days/create_service.rb rename to app/services/user_non_working_times/create_service.rb index f499d172a1a..da143a6b7a1 100644 --- a/app/services/user_non_working_days/create_service.rb +++ b/app/services/user_non_working_times/create_service.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserNonWorkingDays +module UserNonWorkingTimes class CreateService < ::BaseServices::Create end end diff --git a/app/services/user_non_working_days/delete_service.rb b/app/services/user_non_working_times/delete_service.rb similarity index 97% rename from app/services/user_non_working_days/delete_service.rb rename to app/services/user_non_working_times/delete_service.rb index 5c76c078226..303be0a4b43 100644 --- a/app/services/user_non_working_days/delete_service.rb +++ b/app/services/user_non_working_times/delete_service.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserNonWorkingDays +module UserNonWorkingTimes class DeleteService < ::BaseServices::Delete end end diff --git a/app/services/user_non_working_days/set_attributes_service.rb b/app/services/user_non_working_times/set_attributes_service.rb similarity index 97% rename from app/services/user_non_working_days/set_attributes_service.rb rename to app/services/user_non_working_times/set_attributes_service.rb index d8f328b0530..7cbc96bfb2f 100644 --- a/app/services/user_non_working_days/set_attributes_service.rb +++ b/app/services/user_non_working_times/set_attributes_service.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserNonWorkingDays +module UserNonWorkingTimes class SetAttributesService < ::BaseServices::SetAttributes end end diff --git a/app/views/my/non_working_days.html.erb b/app/views/my/non_working_days.html.erb deleted file mode 100644 index d3788c7c789..00000000000 --- a/app/views/my/non_working_days.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= render(My::WorkingTimesHeaderComponent.new) %> - -
<%= @non_working_days.pretty_inspect %>
diff --git a/app/views/my/non_working_times.html.erb b/app/views/my/non_working_times.html.erb new file mode 100644 index 00000000000..6a6b7d0ecc5 --- /dev/null +++ b/app/views/my/non_working_times.html.erb @@ -0,0 +1,11 @@ +<%= render(My::WorkingTimesHeaderComponent.new) %> +<%= render(Users::NonWorkingDays::SubHeaderComponent.new(year: @year)) %> + +<%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> + <% layout.with_column(flex: 1) do %> + <%= render(Users::NonWorkingDays::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> + <% end %> + <% layout.with_column do %> + <%= render(Users::NonWorkingDays::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> + <% end %> +<% end %> diff --git a/app/views/users/non_working_days/_list.html.erb b/app/views/users/non_working_times/_list.html.erb similarity index 69% rename from app/views/users/non_working_days/_list.html.erb rename to app/views/users/non_working_times/_list.html.erb index 91bcfbc3337..6ca9210fc75 100644 --- a/app/views/users/non_working_days/_list.html.erb +++ b/app/views/users/non_working_times/_list.html.erb @@ -27,6 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -

Non-working days for <%= @user.name %> (<%= @year %>)

+<%= render(Users::NonWorkingDays::SubHeaderComponent.new(year: @year)) %> -
<%= @non_working_days.pretty_inspect %>
+<%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> + <% layout.with_column(flex: 1) do %> + <%= render(Users::NonWorkingDays::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> + <% end %> + <% layout.with_column do %> + <%= render(Users::NonWorkingDays::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> + <% end %> +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 263c2a7feb1..872114c6a0a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1803,8 +1803,9 @@ en: users/invitation/form_model: principal_type: "Invitation type" id_or_email: "Name or email address" - user_non_working_days: - date: "Date" + user_non_working_time: + start_date: "Start date" + end_date: "End date" user_working_hours: valid_from: "Valid from" monday: "Monday" @@ -1877,6 +1878,8 @@ en: before: "must be before %{date}." before_or_equal_to: "must be before or equal to %{date}." blank: "can't be blank." + not_before_start_date: "must not be before the start date." + overlapping_range: "overlaps with an existing non-working day range." blank_nested: "needs to have the property '%{property}' set." cannot_delete_mapping: "is required. Cannot be deleted." is_for_all_cannot_modify: "is for all projects and can therefore not be modified." @@ -3885,6 +3888,7 @@ en: label_news_view_all: "View all news" label_next: "Next" label_next_week: "Next week" + label_next_year: "Next year" label_no_change_option: "(No change)" label_no_data: "No data to display" label_no_due_date: "no finish date" @@ -3944,6 +3948,7 @@ en: label_preview_not_available: "Preview not available" label_previous: "Previous" label_previous_week: "Previous week" + label_previous_year: "Previous year" label_principal_invite_via_email: " or invite new users via email" label_principal_search: "Add existing users or groups" label_privacy_policy: "Data privacy and security policy" @@ -4083,6 +4088,7 @@ en: label_title: "Title" label_projects_menu: "Projects" label_today: "today" + label_today_capitalized: "Today" label_token_version: "Token Version" label_today_as_start_date: "Select today as start date." label_today_as_due_date: "Select today as finish date." @@ -4197,6 +4203,9 @@ en: one: "1 file" other: "%{count} files" zero: "no files" + label_x_days: + one: "1 day" + other: "%{count} days" label_yesterday: "yesterday" label_zen_mode: "Zen mode" label_role_type: "Type" @@ -4208,6 +4217,11 @@ en: label_schedule_and_availability: "Schedule and availability" label_working_hours: "Work schedule" label_non_working_days: "Availability calendar" + label_non_working_days_with_count: "Non-working days (%{count})" + label_non_working_days_summary: "Summary" + label_total_user_non_working_times: "Total non-working days" + label_total_global_non_working_days: "Global non-working days" + label_total_days_off: "Total days off" macro_execution_error: "Error executing the macro %{macro_name}" macro_unavailable: "Macro %{macro_name} cannot be displayed." macros: diff --git a/config/routes.rb b/config/routes.rb index 9ef509b71c6..e8fbb37b3e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -921,7 +921,7 @@ Rails.application.routes.draw do resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do resources :memberships, controller: "users/memberships", only: %i[update create destroy] resources :working_hours, controller: "users/working_hours" - resources :non_working_days, controller: "users/non_working_days", only: %i[index create destroy] + resources :non_working_times, controller: "users/non_working_times", only: %i[index create destroy] collection do get "/invite" => "users/invite#start_dialog" @@ -1026,7 +1026,7 @@ Rails.application.routes.draw do get "/my/reminders", action: "reminders" get "/my/working_hours", action: "working_hours" - get "/my/non_working_days", action: "non_working_days" + get "/my/non_working_times", action: "non_working_times" patch "/my/account", action: "update_account" patch "/my/settings", action: "update_settings" diff --git a/db/migrate/20260219152519_add_user_non_working_days.rb b/db/migrate/20260219152519_add_user_non_working_days.rb deleted file mode 100644 index 0822a66eedb..00000000000 --- a/db/migrate/20260219152519_add_user_non_working_days.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class AddUserNonWorkingDays < ActiveRecord::Migration[8.1] - def change - create_table :user_non_working_days do |t| - t.references :user, null: false, foreign_key: true - - t.date :date, null: false, index: true - - t.timestamps - - t.index %i[user_id date], unique: true - end - end -end diff --git a/db/migrate/20260219152519_add_user_non_working_times.rb b/db/migrate/20260219152519_add_user_non_working_times.rb new file mode 100644 index 00000000000..971dcaa6a11 --- /dev/null +++ b/db/migrate/20260219152519_add_user_non_working_times.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class AddUserNonWorkingTimes < ActiveRecord::Migration[8.1] + def change + create_table :user_non_working_times do |t| + t.references :user, null: false, foreign_key: true + + t.date :start_date, null: false + t.date :end_date, null: false + + t.timestamps + end + + reversible do |direction| + direction.up do + execute <<~SQL.squish + ALTER TABLE non_working_times + ADD CONSTRAINT no_overlapping_non_working_times + EXCLUDE USING gist ( + user_id WITH =, + daterange(start_date, end_date, '[]') WITH && + ); + SQL + end + end + end +end diff --git a/docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml b/docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml similarity index 72% rename from docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml rename to docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml index 2def755f69c..1d302ca1340 100644 --- a/docs/api/apiv3/components/schemas/user_non_working_day_collection_model.yml +++ b/docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml @@ -1,4 +1,4 @@ -# Schema: UserNonWorkingDayCollectionModel +# Schema: UserNonWorkingTimeCollectionModel --- allOf: - $ref: "./collection_model.yml" @@ -17,7 +17,7 @@ allOf: - $ref: "./link.yml" - description: |- This collection of non-working day records. - **Resource**: UserNonWorkingDayCollectionModel + **Resource**: UserNonWorkingTimeCollectionModel _embedded: type: object required: @@ -28,7 +28,7 @@ allOf: description: |- The array of personal non-working days for the user, ordered by date ascending. items: - $ref: "./user_non_working_day_model.yml" + $ref: "./user_non_working_time_model.yml" example: _type: Collection @@ -36,24 +36,24 @@ example: count: 2 _links: self: - href: /api/v3/users/42/non_working_days + href: /api/v3/users/42/non_working_times _embedded: elements: - - _type: UserNonWorkingDay + - _type: UserNonWorkingTime id: 7 date: "2025-06-18" _links: self: - href: /api/v3/users/42/non_working_days/2025-06-16 + href: /api/v3/users/42/non_working_times/2025-06-16 user: href: /api/v3/users/42 title: Jane Doe - - _type: UserNonWorkingDay + - _type: UserNonWorkingTime id: 8 date: "2025-12-24" _links: self: - href: /api/v3/users/42/non_working_days/2025-12-24 + href: /api/v3/users/42/non_working_times/2025-12-24 user: href: /api/v3/users/42 title: Jane Doe diff --git a/docs/api/apiv3/components/schemas/user_non_working_day_model.yml b/docs/api/apiv3/components/schemas/user_non_working_time_model.yml similarity index 82% rename from docs/api/apiv3/components/schemas/user_non_working_day_model.yml rename to docs/api/apiv3/components/schemas/user_non_working_time_model.yml index 17b8dc1cf75..8860c8c7bf7 100644 --- a/docs/api/apiv3/components/schemas/user_non_working_day_model.yml +++ b/docs/api/apiv3/components/schemas/user_non_working_time_model.yml @@ -1,4 +1,4 @@ -# Schema: UserNonWorkingDayModel +# Schema: UserNonWorkingTimeModel --- type: object required: @@ -9,7 +9,7 @@ properties: _type: type: string enum: - - UserNonWorkingDay + - UserNonWorkingTime id: type: integer description: |- @@ -32,7 +32,7 @@ properties: - $ref: './link.yml' - description: |- This non-working day record. - **Resource**: UserNonWorkingDay + **Resource**: UserNonWorkingTime user: allOf: - $ref: './link.yml' @@ -41,12 +41,12 @@ properties: **Resource**: User example: - _type: UserNonWorkingDay + _type: UserNonWorkingTime id: 7 date: '2025-06-16' _links: self: - href: /api/v3/users/42/non_working_days/2025-06-16 + href: /api/v3/users/42/non_working_times/2025-06-16 user: href: /api/v3/users/42 title: Jane Doe diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index b3d611f5233..f5e472e2e0c 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -482,10 +482,10 @@ paths: "$ref": "./paths/user_form.yml" "/api/v3/users/{id}/lock": "$ref": "./paths/user_lock.yml" - "/api/v3/users/{id}/non_working_days": - "$ref": "./paths/user_non_working_days.yml" - "/api/v3/users/{id}/non_working_days/{date}": - "$ref": "./paths/user_non_working_days_date.yml" + "/api/v3/users/{id}/non_working_times": + "$ref": "./paths/user_non_working_times.yml" + "/api/v3/users/{id}/non_working_times/{date}": + "$ref": "./paths/user_non_working_times_date.yml" "/api/v3/users/{id}/working_hours": "$ref": "./paths/user_working_hours.yml" "/api/v3/users/{id}/working_hours/{working_hours_id}": @@ -1011,10 +1011,10 @@ components: "$ref": "./components/schemas/user_create_model.yml" UserModel: "$ref": "./components/schemas/user_model.yml" - UserNonWorkingDayCollectionModel: - "$ref": "./components/schemas/user_non_working_day_collection_model.yml" - UserNonWorkingDayModel: - "$ref": "./components/schemas/user_non_working_day_model.yml" + UserNonWorkingTimeCollectionModel: + "$ref": "./components/schemas/user_non_working_time_collection_model.yml" + UserNonWorkingTimeModel: + "$ref": "./components/schemas/user_non_working_time_model.yml" UserPreferencesModel: "$ref": "./components/schemas/user_preferences_model.yml" UserWorkingHoursCollectionModel: diff --git a/docs/api/apiv3/paths/user_non_working_days.yml b/docs/api/apiv3/paths/user_non_working_times.yml similarity index 93% rename from docs/api/apiv3/paths/user_non_working_days.yml rename to docs/api/apiv3/paths/user_non_working_times.yml index ea6ea5a8478..361ec167e7b 100644 --- a/docs/api/apiv3/paths/user_non_working_days.yml +++ b/docs/api/apiv3/paths/user_non_working_times.yml @@ -1,8 +1,8 @@ -# /api/v3/users/{id}/non_working_days +# /api/v3/users/{id}/non_working_times --- get: summary: List personal non-working days for a user - operationId: list_user_non_working_days + operationId: list_user_non_working_times tags: - User Working Times description: |- @@ -40,7 +40,7 @@ get: content: application/hal+json: schema: - $ref: "../components/schemas/user_non_working_day_collection_model.yml" + $ref: "../components/schemas/user_non_working_time_collection_model.yml" "401": content: application/hal+json: @@ -64,7 +64,7 @@ get: post: summary: Create a personal non-working day for a user - operationId: create_user_non_working_day + operationId: create_user_non_working_time tags: - User Working Times description: |- @@ -91,7 +91,7 @@ post: content: application/json: schema: - $ref: "../components/schemas/user_non_working_day_model.yml" + $ref: "../components/schemas/user_non_working_time_model.yml" example: date: "2025-06-16" responses: @@ -100,7 +100,7 @@ post: content: application/hal+json: schema: - $ref: "../components/schemas/user_non_working_day_model.yml" + $ref: "../components/schemas/user_non_working_time_model.yml" "400": $ref: "../components/responses/invalid_request_body.yml" "401": diff --git a/docs/api/apiv3/paths/user_non_working_days_date.yml b/docs/api/apiv3/paths/user_non_working_times_date.yml similarity index 96% rename from docs/api/apiv3/paths/user_non_working_days_date.yml rename to docs/api/apiv3/paths/user_non_working_times_date.yml index decfde9472d..7ed004938bd 100644 --- a/docs/api/apiv3/paths/user_non_working_days_date.yml +++ b/docs/api/apiv3/paths/user_non_working_times_date.yml @@ -1,8 +1,8 @@ -# /api/v3/users/{id}/non_working_days/{date} +# /api/v3/users/{id}/non_working_times/{date} --- delete: summary: Delete a personal non-working day - operationId: delete_user_non_working_day + operationId: delete_user_non_working_time tags: - User Working Times description: |- diff --git a/docs/api/apiv3/tags/user_working_times.yml b/docs/api/apiv3/tags/user_working_times.yml index 06c9cfef656..4ee1ee793e2 100644 --- a/docs/api/apiv3/tags/user_working_times.yml +++ b/docs/api/apiv3/tags/user_working_times.yml @@ -10,7 +10,7 @@ description: |- most recently effective record (i.e., the one with the latest `validFrom` that is not in the future) is used for capacity calculations. - A `UserNonWorkingDay` marks a specific calendar date as a non-working day for a user + A `UserNonWorkingTime` marks a specific calendar date as a non-working day for a user (e.g., a personal day off or a local holiday not covered by the system-wide non-working days). A user cannot have a personal non-working day on a date that is already a system-wide non-working day. @@ -52,27 +52,27 @@ description: |- - All users can read their own working hours records even without a special permission. - Records that have already taken effect (i.e., `validFrom` is today or in the past) cannot be updated. - ## UserNonWorkingDay Actions + ## UserNonWorkingTime Actions | Link | Description | Condition | | :----: | -------------------------------- | ------------------------ | | delete | Delete this non-working day | **Permission**: see below | - ## UserNonWorkingDay Linked Properties + ## UserNonWorkingTime Linked Properties | Link | Description | Type | Constraints | Supported operations | | :--: | ---------------------------------------------------- | ------------------ | ----------- | -------------------- | - | self | This non-working day | UserNonWorkingDay | not null | READ | + | self | This non-working day | UserNonWorkingTime | not null | READ | | user | The user this non-working day belongs to | User | not null | READ | - ## UserNonWorkingDay Local Properties + ## UserNonWorkingTime Local Properties | Property | Description | Type | Constraints | Supported operations | | :------: | ------------------------------------------------------- | ------- | ----------- | -------------------- | | id | The unique identifier of the record | Integer | x > 0 | READ | | date | The date of the non-working day (ISO 8601 format) | Date | not null | READ / WRITE | - ## UserNonWorkingDay Permissions + ## UserNonWorkingTime Permissions - **Administrators** can read and manage personal non-working days for any user. - Users with the global **`manage_own_working_times`** permission can read and manage their own non-working days. diff --git a/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb b/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb similarity index 72% rename from lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb rename to lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb index 4a5cd54e335..b4a143815cb 100644 --- a/lib/api/v3/user_non_working_days/non_working_days_by_user_api.rb +++ b/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb @@ -30,9 +30,9 @@ module API module V3 - module UserNonWorkingDays - class NonWorkingDaysByUserAPI < ::API::OpenProjectAPI - resource :non_working_days do + module UserNonWorkingTimes + class NonWorkingTimesByUserAPI < ::API::OpenProjectAPI + resource :non_working_times do after_validation do guard_feature_flag :user_working_times end @@ -42,33 +42,33 @@ module API end get do year = params[:year] || Date.current.year - records = ::UserNonWorkingDay + records = ::UserNonWorkingTime .visible(current_user) .for_user(@user) .for_year(year) - .order(:date) + .order(:start_date) - UserNonWorkingDayCollectionRepresenter.new( + UserNonWorkingTimeCollectionRepresenter.new( records, - self_link: api_v3_paths.user_non_working_days(@user.id), + self_link: api_v3_paths.user_non_working_times(@user.id), current_user: ) end post &::API::V3::Utilities::Endpoints::Create.new( - model: ::UserNonWorkingDay, + model: ::UserNonWorkingTime, params_modifier: ->(params) { params.merge(user: @user) } ).mount - route_param :date, type: Date, desc: "UserNonWorkingDay date" do + route_param :non_working_time_id, type: Integer, desc: "UserNonWorkingTime id" do after_validation do - @user_non_working_day = ::UserNonWorkingDay - .visible(current_user) - .for_user(@user) - .find_by!(date: declared_params[:date]) + @user_non_working_time = ::UserNonWorkingTime + .visible(current_user) + .for_user(@user) + .find(declared_params[:non_working_time_id]) end - delete &::API::V3::Utilities::Endpoints::Delete.new(model: ::UserNonWorkingDay).mount + delete &::API::V3::Utilities::Endpoints::Delete.new(model: ::UserNonWorkingTime).mount end end end diff --git a/lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb b/lib/api/v3/user_non_working_times/user_non_working_time_collection_representer.rb similarity index 90% rename from lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb rename to lib/api/v3/user_non_working_times/user_non_working_time_collection_representer.rb index 18dfae15b63..57426393ef7 100644 --- a/lib/api/v3/user_non_working_days/user_non_working_day_collection_representer.rb +++ b/lib/api/v3/user_non_working_times/user_non_working_time_collection_representer.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module API::V3::UserNonWorkingDays - class UserNonWorkingDayCollectionRepresenter < ::API::Decorators::UnpaginatedCollection +module API::V3::UserNonWorkingTimes + class UserNonWorkingTimeCollectionRepresenter < ::API::Decorators::UnpaginatedCollection end end diff --git a/lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb b/lib/api/v3/user_non_working_times/user_non_working_time_payload_representer.rb similarity index 91% rename from lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb rename to lib/api/v3/user_non_working_times/user_non_working_time_payload_representer.rb index 5bfe6c37cee..12be3d2ad4f 100644 --- a/lib/api/v3/user_non_working_days/user_non_working_day_payload_representer.rb +++ b/lib/api/v3/user_non_working_times/user_non_working_time_payload_representer.rb @@ -28,8 +28,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module API::V3::UserNonWorkingDays - class UserNonWorkingDayPayloadRepresenter < UserNonWorkingDayRepresenter +module API::V3::UserNonWorkingTimes + class UserNonWorkingTimePayloadRepresenter < UserNonWorkingTimeRepresenter include ::API::Utilities::PayloadRepresenter end end diff --git a/lib/api/v3/user_non_working_days/user_non_working_day_representer.rb b/lib/api/v3/user_non_working_times/user_non_working_time_representer.rb similarity index 85% rename from lib/api/v3/user_non_working_days/user_non_working_day_representer.rb rename to lib/api/v3/user_non_working_times/user_non_working_time_representer.rb index ad40bdb6e47..2ac1e947f07 100644 --- a/lib/api/v3/user_non_working_days/user_non_working_day_representer.rb +++ b/lib/api/v3/user_non_working_times/user_non_working_time_representer.rb @@ -30,14 +30,14 @@ module API module V3 - module UserNonWorkingDays - class UserNonWorkingDayRepresenter < ::API::Decorators::Single + module UserNonWorkingTimes + class UserNonWorkingTimeRepresenter < ::API::Decorators::Single include ::API::Decorators::DateProperty include ::API::Decorators::LinkedResource link :self do { - href: api_v3_paths.user_non_working_day(represented.user_id, represented.date) + href: api_v3_paths.user_non_working_time(represented.user_id, represented.id) } end @@ -50,10 +50,11 @@ module API property :id - date_property :date + date_property :start_date + date_property :end_date def _type - "UserNonWorkingDay" + "UserNonWorkingTime" end end end diff --git a/lib/api/v3/users/users_api.rb b/lib/api/v3/users/users_api.rb index b5a5bf3c988..7ddeabdfad5 100644 --- a/lib/api/v3/users/users_api.rb +++ b/lib/api/v3/users/users_api.rb @@ -91,7 +91,7 @@ module API mount ::API::V3::Users::UpdateFormAPI mount ::API::V3::UserPreferences::PreferencesByUserAPI mount ::API::V3::UserWorkingHours::WorkingHoursByUserAPI - mount ::API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI + mount ::API::V3::UserNonWorkingTimes::NonWorkingTimesByUserAPI namespace :lock do # Authenticate lock transitions diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 09bcf6054c2..8844e3f6f7c 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -602,12 +602,12 @@ module API "#{user_working_hours(user_id)}/#{id}" end - def self.user_non_working_days(user_id) - "#{user(user_id)}/non_working_days" + def self.user_non_working_times(user_id) + "#{user(user_id)}/non_working_times" end - def self.user_non_working_day(user_id, date) - "#{user_non_working_days(user_id)}/#{date}" + def self.user_non_working_time(user_id, non_working_time_id) + "#{user_non_working_times(user_id)}/#{non_working_time_id}" end def self.my_preferences diff --git a/lib/open_project/ui/extensible_tabs.rb b/lib/open_project/ui/extensible_tabs.rb index cbad77bbed1..46461d239ea 100644 --- a/lib/open_project/ui/extensible_tabs.rb +++ b/lib/open_project/ui/extensible_tabs.rb @@ -74,9 +74,9 @@ module OpenProject only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, { - name: "non_working_days", - partial: "users/non_working_days/list", - path: ->(params) { user_non_working_days_path(params[:user]) }, + name: "non_working_times", + partial: "users/non_working_times/list", + path: ->(params) { user_non_working_times_path(params[:user]) }, label: :label_non_working_days, only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, diff --git a/spec/contracts/user_non_working_days/create_contract_spec.rb b/spec/contracts/user_non_working_times/create_contract_spec.rb similarity index 62% rename from spec/contracts/user_non_working_days/create_contract_spec.rb rename to spec/contracts/user_non_working_times/create_contract_spec.rb index cc99f5b1d4d..ef43a12b253 100644 --- a/spec/contracts/user_non_working_days/create_contract_spec.rb +++ b/spec/contracts/user_non_working_times/create_contract_spec.rb @@ -31,22 +31,15 @@ require "spec_helper" require "contracts/shared/model_contract_shared_context" -RSpec.describe UserNonWorkingDays::CreateContract do +RSpec.describe UserNonWorkingTimes::CreateContract do include_context "ModelContract shared context" let(:target_user) { build_stubbed(:user) } let(:current_user) { build_stubbed(:user) } - let(:date) { Date.tomorrow } - let(:non_working_day) { build_stubbed(:user_non_working_day, user: target_user, date:) } - let(:contract) { described_class.new(non_working_day, current_user) } - - before do - allow(NonWorkingDay).to receive(:exists?).with(date:).and_return(false) - end + let(:non_working_time) { build_stubbed(:user_non_working_time, user: target_user) } + let(:contract) { described_class.new(non_working_time, current_user) } context "with global manage_working_times permission" do - let(:current_user) { build_stubbed(:user) } - before do mock_permissions_for(current_user) do |mock| mock.allow_globally(:manage_working_times) @@ -69,8 +62,6 @@ RSpec.describe UserNonWorkingDays::CreateContract do end context "with manage_own_working_times but not owning the record" do - let(:current_user) { build_stubbed(:user) } - before do mock_permissions_for(current_user) do |mock| mock.allow_globally(:manage_own_working_times) @@ -81,40 +72,8 @@ RSpec.describe UserNonWorkingDays::CreateContract do end context "without any relevant permissions" do - let(:current_user) { build_stubbed(:user) } - it_behaves_like "contract is invalid", base: :error_unauthorized end - context "when a system-wide non-working day exists for the same date" do - let(:current_user) { build_stubbed(:user) } - - before do - allow(NonWorkingDay).to receive(:exists?).with(date:).and_return(true) - mock_permissions_for(current_user) do |mock| - mock.allow_globally(:manage_working_times) - end - end - - it_behaves_like "contract is invalid", date: :system_wide_non_working_day_exists - end - - context "when the date is blank" do - let(:current_user) { build_stubbed(:user) } - let(:date) { nil } - - before do - allow(NonWorkingDay).to receive(:exists?).with(date: nil).and_return(false) - mock_permissions_for(current_user) do |mock| - mock.allow_globally(:manage_working_times) - end - end - - it "does not add a system_wide_non_working_day_exists error" do - contract.validate - expect(contract.errors.symbols_for(:date)).not_to include(:system_wide_non_working_day_exists) - end - end - include_examples "contract reuses the model errors" end diff --git a/spec/contracts/user_non_working_days/delete_contract_spec.rb b/spec/contracts/user_non_working_times/delete_contract_spec.rb similarity index 95% rename from spec/contracts/user_non_working_days/delete_contract_spec.rb rename to spec/contracts/user_non_working_times/delete_contract_spec.rb index 9b6397e81e4..dd3986ddc7a 100644 --- a/spec/contracts/user_non_working_days/delete_contract_spec.rb +++ b/spec/contracts/user_non_working_times/delete_contract_spec.rb @@ -31,12 +31,12 @@ require "spec_helper" require "contracts/shared/model_contract_shared_context" -RSpec.describe UserNonWorkingDays::DeleteContract do +RSpec.describe UserNonWorkingTimes::DeleteContract do include_context "ModelContract shared context" let(:target_user) { build_stubbed(:user) } let(:current_user) { build_stubbed(:user) } - let(:non_working_day) { build_stubbed(:user_non_working_day, user: target_user) } + let(:non_working_day) { build_stubbed(:user_non_working_time, user: target_user) } let(:contract) { described_class.new(non_working_day, current_user) } context "with global manage_working_times permission" do diff --git a/spec/factories/user_non_working_day_factory.rb b/spec/factories/user_non_working_time_factory.rb similarity index 85% rename from spec/factories/user_non_working_day_factory.rb rename to spec/factories/user_non_working_time_factory.rb index 45ba851eb87..e752cf0fb5e 100644 --- a/spec/factories/user_non_working_day_factory.rb +++ b/spec/factories/user_non_working_time_factory.rb @@ -29,8 +29,13 @@ #++ FactoryBot.define do - factory :user_non_working_day do + factory :user_non_working_time do user - sequence(:date) { |n| Date.current + n.days } + sequence(:start_date) { |n| Date.new(2025, 1, 1) + ((n - 1) * 7).days } + end_date { start_date } + + trait :with_date_range do + end_date { start_date + 1.week } + end end end diff --git a/spec/models/user_non_working_day_spec.rb b/spec/models/user_non_working_day_spec.rb deleted file mode 100644 index 7ba88e448ed..00000000000 --- a/spec/models/user_non_working_day_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" - -RSpec.describe UserNonWorkingDay do - subject(:non_working_day) { build(:user_non_working_day) } - - describe "validations" do - it { is_expected.to be_valid } - - it { is_expected.to validate_presence_of(:date) } - - it "validates uniqueness of date scoped to user" do - existing = create(:user_non_working_day) - duplicate = build(:user_non_working_day, user: existing.user, date: existing.date) - - expect(duplicate).not_to be_valid - expect(duplicate.errors[:date]).to be_present - end - - it "allows the same date for different users" do - existing = create(:user_non_working_day) - other_user = create(:user) - other = build(:user_non_working_day, user: other_user, date: existing.date) - - expect(other).to be_valid - end - end - - describe ".for_year" do - let(:user) { create(:user) } - let!(:day_in_year) { create(:user_non_working_day, user:, date: Date.new(2025, 6, 15)) } - let!(:day_at_start) { create(:user_non_working_day, user:, date: Date.new(2025, 1, 1)) } - let!(:day_at_end) { create(:user_non_working_day, user:, date: Date.new(2025, 12, 31)) } - let!(:day_outside_year) { create(:user_non_working_day, user:, date: Date.new(2024, 12, 31)) } - - it "returns records within the given year" do - expect(described_class.for_user(user).for_year(2025)).to contain_exactly(day_in_year, day_at_start, day_at_end) - end - - it "excludes records outside the given year" do - expect(described_class.for_user(user).for_year(2025)).not_to include(day_outside_year) - end - end - - describe ".for_user" do - let(:user) { create(:user) } - let(:other_user) { create(:user) } - let!(:user_day) { create(:user_non_working_day, user:) } - let!(:other_day) { create(:user_non_working_day, user: other_user) } - - it "returns only records for the given user" do - expect(described_class.for_user(user)).to contain_exactly(user_day) - end - - it "excludes records for other users" do - expect(described_class.for_user(user)).not_to include(other_day) - end - end - - describe ".visible" do - let(:user) { create(:user) } - let(:other_user) { create(:user) } - let!(:user_day) { create(:user_non_working_day, user:) } - let!(:other_day) { create(:user_non_working_day, user: other_user) } - - context "when the viewer has :manage_working_times permission" do - let(:viewer) { create(:user, global_permissions: [:manage_working_times]) } - - it "returns all records" do - expect(described_class.visible(viewer)).to contain_exactly(user_day, other_day) - end - end - - context "when the viewer has no special permissions" do - let(:viewer) { create(:user) } - let!(:viewer_day) { create(:user_non_working_day, user: viewer) } - - it "returns only their own records" do - expect(described_class.visible(viewer)).to contain_exactly(viewer_day) - end - - it "excludes other users' records" do - expect(described_class.visible(viewer)).not_to include(user_day, other_day) - end - end - end -end diff --git a/spec/models/user_non_working_time_spec.rb b/spec/models/user_non_working_time_spec.rb new file mode 100644 index 00000000000..8a40d192ba4 --- /dev/null +++ b/spec/models/user_non_working_time_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe UserNonWorkingTime do + subject(:non_working_day) { build(:user_non_working_time) } + + describe "validations" do + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:start_date) } + it { is_expected.to validate_presence_of(:end_date) } + + context "when end_date is before start_date" do + subject(:non_working_day) do + build(:user_non_working_time, start_date: Date.new(2025, 6, 10), end_date: Date.new(2025, 6, 5)) + end + + it { is_expected.not_to be_valid } + + it "adds an error on end_date" do + non_working_day.valid? + expect(non_working_day.errors[:end_date]).to be_present + end + end + + context "when end_date equals start_date" do + subject(:non_working_day) do + build(:user_non_working_time, start_date: Date.new(2025, 6, 10), end_date: Date.new(2025, 6, 10)) + end + + it { is_expected.to be_valid } + end + + describe "no overlapping ranges" do + let(:user) { create(:user) } + let!(:existing) do + create(:user_non_working_time, user:, start_date: Date.new(2025, 6, 10), end_date: Date.new(2025, 6, 20)) + end + + it "is invalid when the new range is contained within an existing range" do + record = build(:user_non_working_time, user:, start_date: Date.new(2025, 6, 12), end_date: Date.new(2025, 6, 15)) + expect(record).not_to be_valid + expect(record.errors[:start_date]).to be_present + end + + it "is invalid when the new range overlaps the start of an existing range" do + record = build(:user_non_working_time, user:, start_date: Date.new(2025, 6, 5), end_date: Date.new(2025, 6, 12)) + expect(record).not_to be_valid + end + + it "is invalid when the new range overlaps the end of an existing range" do + record = build(:user_non_working_time, user:, start_date: Date.new(2025, 6, 18), end_date: Date.new(2025, 6, 25)) + expect(record).not_to be_valid + end + + it "is invalid when the new range fully contains an existing range" do + record = build(:user_non_working_time, user:, start_date: Date.new(2025, 6, 8), end_date: Date.new(2025, 6, 25)) + expect(record).not_to be_valid + end + + it "is valid when the new range is adjacent but does not overlap" do + record = build(:user_non_working_time, user:, start_date: Date.new(2025, 6, 21), end_date: Date.new(2025, 6, 25)) + expect(record).to be_valid + end + + it "is valid when the new range is for a different user" do + other_user = create(:user) + record = build(:user_non_working_time, user: other_user, start_date: Date.new(2025, 6, 12), + end_date: Date.new(2025, 6, 15)) + expect(record).to be_valid + end + + it "does not flag the record as overlapping with itself when updating" do + existing.end_date = Date.new(2025, 6, 22) + expect(existing).to be_valid + end + end + end + + describe "#days" do + subject(:record) { build(:user_non_working_time, start_date: Date.new(2025, 6, 9), end_date: Date.new(2025, 6, 15)) } + + it "returns an inclusive range from start_date to end_date" do + expect(record.days).to eq(Date.new(2025, 6, 9)..Date.new(2025, 6, 15)) + end + end + + describe "#calendar_days_count" do + it "counts the total number of calendar days in the range" do + record = build(:user_non_working_time, start_date: Date.new(2025, 6, 9), end_date: Date.new(2025, 6, 15)) + expect(record.calendar_days_count).to eq(7) + end + + it "returns 1 for a single-day range" do + record = build(:user_non_working_time, start_date: Date.new(2025, 6, 9), end_date: Date.new(2025, 6, 9)) + expect(record.calendar_days_count).to eq(1) + end + end + + # June 9 (Mon) – June 15 (Sun) 2025: Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6, Sun=7 + describe "#working_days", with_settings: { working_days: [1, 2, 3, 4, 5] } do + subject(:record) { build(:user_non_working_time, start_date: Date.new(2025, 6, 9), end_date: Date.new(2025, 6, 15)) } + + it "returns only the working days within the range" do + expect(record.working_days).to contain_exactly( + Date.new(2025, 6, 9), # Monday + Date.new(2025, 6, 10), # Tuesday + Date.new(2025, 6, 11), # Wednesday + Date.new(2025, 6, 12), # Thursday + Date.new(2025, 6, 13) # Friday + ) + end + + context "with Saturday also a working day", with_settings: { working_days: [1, 2, 3, 4, 5, 6] } do + it "includes Saturday but not Sunday" do + expect(record.working_days).to include(Date.new(2025, 6, 14)) + expect(record.working_days).not_to include(Date.new(2025, 6, 15)) + end + end + + context "when some days in the range are system-wide non-working days" do + let!(:system_holiday) { create(:non_working_day, date: Date.new(2025, 6, 11)) } # Wednesday + + it "excludes system-wide non-working days" do + expect(record.working_days).not_to include(Date.new(2025, 6, 11)) + end + + it "still includes the other working days" do + expect(record.working_days).to contain_exactly( + Date.new(2025, 6, 9), # Monday + Date.new(2025, 6, 10), # Tuesday + Date.new(2025, 6, 12), # Thursday + Date.new(2025, 6, 13) # Friday + ) + end + end + end + + describe "#working_days_count", with_settings: { working_days: [1, 2, 3, 4, 5] } do + it "returns the count of working days in the range" do + record = build(:user_non_working_time, start_date: Date.new(2025, 6, 9), end_date: Date.new(2025, 6, 15)) + expect(record.working_days_count).to eq(5) + end + + it "returns 0 for a weekend-only range" do + record = build(:user_non_working_time, start_date: Date.new(2025, 6, 14), end_date: Date.new(2025, 6, 15)) + expect(record.working_days_count).to eq(0) + end + + it "does not count system-wide non-working days" do + create(:non_working_day, date: Date.new(2025, 6, 11)) # Wednesday + record = build(:user_non_working_time, start_date: Date.new(2025, 6, 9), end_date: Date.new(2025, 6, 15)) + expect(record.working_days_count).to eq(4) + end + end + + describe ".for_year" do + let(:user) { create(:user) } + let!(:range_within_year) do + create(:user_non_working_time, user:, start_date: Date.new(2025, 3, 1), end_date: Date.new(2025, 3, 5)) + end + let!(:range_at_year_start) do + create(:user_non_working_time, user:, start_date: Date.new(2025, 1, 1), end_date: Date.new(2025, 1, 3)) + end + let!(:range_at_year_end) do + create(:user_non_working_time, user:, start_date: Date.new(2025, 12, 25), end_date: Date.new(2025, 12, 27)) + end + let!(:range_spanning_year_boundary) do + create(:user_non_working_time, user:, start_date: Date.new(2025, 12, 29), end_date: Date.new(2026, 1, 4)) + end + let!(:range_outside_year) do + create(:user_non_working_time, user:, start_date: Date.new(2024, 12, 1), end_date: Date.new(2024, 12, 15)) + end + + it "includes ranges within the year" do + expect(described_class.for_user(user).for_year(2025)) + .to include(range_within_year, range_at_year_start, range_at_year_end) + end + + it "includes ranges that span the year boundary" do + expect(described_class.for_user(user).for_year(2025)).to include(range_spanning_year_boundary) + expect(described_class.for_user(user).for_year(2026)).to include(range_spanning_year_boundary) + end + + it "excludes ranges entirely outside the year" do + expect(described_class.for_user(user).for_year(2025)).not_to include(range_outside_year) + end + end + + describe ".for_user" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_day) { create(:user_non_working_time, user:) } + let!(:other_day) { create(:user_non_working_time, user: other_user) } + + it "returns only records for the given user" do + expect(described_class.for_user(user)).to contain_exactly(user_day) + end + + it "excludes records for other users" do + expect(described_class.for_user(user)).not_to include(other_day) + end + end + + describe ".visible" do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:user_day) { create(:user_non_working_time, user:) } + let!(:other_day) { create(:user_non_working_time, user: other_user) } + + context "when the viewer has :manage_working_times permission" do + let(:viewer) { create(:user, global_permissions: [:manage_working_times]) } + + it "returns all records" do + expect(described_class.visible(viewer)).to contain_exactly(user_day, other_day) + end + end + + context "when the viewer has no special permissions" do + let(:viewer) { create(:user) } + let!(:viewer_day) { create(:user_non_working_time, user: viewer) } + + it "returns only their own records" do + expect(described_class.visible(viewer)).to contain_exactly(viewer_day) + end + + it "excludes other users' records" do + expect(described_class.visible(viewer)).not_to include(user_day, other_day) + end + end + end +end diff --git a/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb b/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb similarity index 64% rename from spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb rename to spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb index 122a4216bd7..b3395fc9159 100644 --- a/spec/requests/api/v3/user_non_working_days/non_working_days_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb @@ -30,39 +30,38 @@ require "spec_helper" -RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do +RSpec.describe API::V3::UserNonWorkingTimes::NonWorkingTimesByUserAPI do include API::V3::Utilities::PathHelper - # Admin users can see all users and manage all working times. let(:admin_user) { create(:admin) } let(:target_user) { create(:user) } let(:headers) { { "CONTENT_TYPE" => "application/json" } } - let!(:non_working_day_last) { create(:user_non_working_day, user: target_user, date: 1.year.ago) } - let!(:non_working_day) { create(:user_non_working_day, user: target_user, date: Date.tomorrow) } + let!(:non_working_time_last_year) { create(:user_non_working_time, user: target_user, start_date: 1.year.ago.to_date) } + let!(:non_working_time) { create(:user_non_working_time, user: target_user, start_date: Date.tomorrow) } context "with feature disabled", with_flag: { user_working_times: false } do current_user { admin_user } - it "returns 404 for GET /api/v3/users/:user_id/non_working_days" do - get api_v3_paths.user_non_working_days(target_user.id) + it "returns 404 for GET /api/v3/users/:user_id/non_working_times" do + get api_v3_paths.user_non_working_times(target_user.id) expect(last_response).to have_http_status(404) end - it "returns 404 for POST /api/v3/users/:user_id/non_working_days" do - post api_v3_paths.user_non_working_days(target_user.id), {}.to_json, headers + it "returns 404 for POST /api/v3/users/:user_id/non_working_times" do + post api_v3_paths.user_non_working_times(target_user.id), {}.to_json, headers expect(last_response).to have_http_status(404) end - it "returns 404 for DELETE /api/v3/users/:user_id/non_working_days/:date" do - delete api_v3_paths.user_non_working_day(target_user.id, non_working_day.date) + it "returns 404 for DELETE /api/v3/users/:user_id/non_working_times/:id" do + delete api_v3_paths.user_non_working_time(target_user.id, non_working_time.id) expect(last_response).to have_http_status(404) end end context "with feature enabled", with_flag: { user_working_times: true } do - describe "GET /api/v3/users/:user_id/non_working_days" do - let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + describe "GET /api/v3/users/:user_id/non_working_times" do + let(:path) { api_v3_paths.user_non_working_times(target_user.id) } context "with admin user" do current_user { admin_user } @@ -73,7 +72,7 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do expect(last_response).to have_http_status(200) end - it "returns a collection of non-working days for the current year" do + it "returns a collection of non-working times for the current year" do expect(last_response.body).to be_json_eql("Collection".to_json).at_path("_type") expect(last_response.body).to be_json_eql(1.to_json).at_path("total") end @@ -81,12 +80,12 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do context "with own user" do let(:own_user) { create(:user) } - let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } - let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } + let!(:own_time_last_year) { create(:user_non_working_time, user: own_user, start_date: 1.year.ago.to_date) } + let!(:own_time) { create(:user_non_working_time, user: own_user, start_date: Date.tomorrow + 1.week) } current_user { own_user } - before { get api_v3_paths.user_non_working_days(own_user.id) } + before { get api_v3_paths.user_non_working_times(own_user.id) } it "returns 200 OK" do expect(last_response).to have_http_status(200) @@ -99,12 +98,12 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do context "with 'me' as the user ID" do let(:own_user) { create(:user) } - let!(:own_day_last_year) { create(:user_non_working_day, user: own_user, date: 1.year.ago) } - let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 1.day) } + let!(:own_time_last_year) { create(:user_non_working_time, user: own_user, start_date: 1.year.ago.to_date) } + let!(:own_time) { create(:user_non_working_time, user: own_user, start_date: Date.tomorrow + 1.week) } current_user { own_user } - before { get api_v3_paths.user_non_working_days("me") } + before { get api_v3_paths.user_non_working_times("me") } it "returns 200 OK" do expect(last_response).to have_http_status(200) @@ -121,7 +120,6 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do before { get path } it "returns 404 since the user is not visible" do - # The user API returns 404 when User.visible doesn't include the target user expect(last_response).to have_http_status(404) end end @@ -139,21 +137,23 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do get "#{path}?year=#{Date.current.year - 1}" expect(last_response).to have_http_status(200) expect(last_response.body).to be_json_eql(1.to_json).at_path("total") - expect(last_response.body).to be_json_eql(1.year.ago.to_date.iso8601.to_json).at_path("_embedded/elements/0/date") + expect(last_response.body).to be_json_eql(non_working_time_last_year.start_date.iso8601.to_json) + .at_path("_embedded/elements/0/startDate") end end it_behaves_like "handling anonymous user" do - let(:path) { api_v3_paths.user_non_working_days(target_user.id) } + let(:path) { api_v3_paths.user_non_working_times(target_user.id) } before { get path } end end - describe "POST /api/v3/users/:user_id/non_working_days" do - let(:path) { api_v3_paths.user_non_working_days(target_user.id) } - let(:new_date) { (Date.tomorrow + 1.week).iso8601 } - let(:valid_params) { { date: new_date } } + describe "POST /api/v3/users/:user_id/non_working_times" do + let(:path) { api_v3_paths.user_non_working_times(target_user.id) } + let(:start_date) { (Date.tomorrow + 1.month).iso8601 } + let(:end_date) { (Date.tomorrow + 1.month + 4.days).iso8601 } + let(:valid_params) { { startDate: start_date, endDate: end_date } } context "with admin user" do current_user { admin_user } @@ -164,22 +164,11 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do expect(last_response).to have_http_status(201) end - it "creates a non-working day for the target user" do + it "creates a non-working time for the target user" do parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserNonWorkingDay") - expect(parsed["date"]).to eq(new_date) - end - end - - context "when a system-wide NonWorkingDay exists for the same date" do - let!(:system_non_working_day) { create(:non_working_day, date: Date.parse(new_date)) } - - current_user { admin_user } - - before { post path, valid_params.to_json, headers } - - it "returns 422 Unprocessable Entity" do - expect(last_response).to have_http_status(422) + expect(parsed["_type"]).to eq("UserNonWorkingTime") + expect(parsed["startDate"]).to eq(start_date) + expect(parsed["endDate"]).to eq(end_date) end end @@ -188,16 +177,17 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do current_user { own_user } - before { post api_v3_paths.user_non_working_days("me"), valid_params.to_json, headers } + before { post api_v3_paths.user_non_working_times("me"), valid_params.to_json, headers } it "returns 201 Created" do expect(last_response).to have_http_status(201) end - it "creates a non-working day for the current user" do + it "creates a non-working time for the current user" do parsed = JSON.parse(last_response.body) - expect(parsed["_type"]).to eq("UserNonWorkingDay") - expect(parsed["date"]).to eq(new_date) + expect(parsed["_type"]).to eq("UserNonWorkingTime") + expect(parsed["startDate"]).to eq(start_date) + expect(parsed["endDate"]).to eq(end_date) end end @@ -212,8 +202,8 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do end end - describe "DELETE /api/v3/users/:user_id/non_working_days/:date" do - let(:path) { api_v3_paths.user_non_working_day(target_user.id, non_working_day.date) } + describe "DELETE /api/v3/users/:user_id/non_working_times/:id" do + let(:path) { api_v3_paths.user_non_working_time(target_user.id, non_working_time.id) } context "with admin user" do current_user { admin_user } @@ -225,24 +215,24 @@ RSpec.describe API::V3::UserNonWorkingDays::NonWorkingDaysByUserAPI do end it "deletes the record" do - expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + expect(UserNonWorkingTime.find_by(id: non_working_time.id)).to be_nil end end context "with 'me' as the user ID with manage_own_working_times permission" do let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } - let!(:own_day) { create(:user_non_working_day, user: own_user, date: Date.tomorrow + 2.days) } + let!(:own_time) { create(:user_non_working_time, user: own_user, start_date: Date.tomorrow + 2.weeks) } current_user { own_user } - before { delete api_v3_paths.user_non_working_day("me", own_day.date) } + before { delete api_v3_paths.user_non_working_time("me", own_time.id) } it "returns 204 No Content" do expect(last_response).to have_http_status(204) end it "deletes the record" do - expect(UserNonWorkingDay.find_by(id: own_day.id)).to be_nil + expect(UserNonWorkingTime.find_by(id: own_time.id)).to be_nil end end diff --git a/spec/services/user_non_working_days/create_service_spec.rb b/spec/services/user_non_working_times/create_service_spec.rb similarity index 68% rename from spec/services/user_non_working_days/create_service_spec.rb rename to spec/services/user_non_working_times/create_service_spec.rb index 2996b513131..a2f175730db 100644 --- a/spec/services/user_non_working_days/create_service_spec.rb +++ b/spec/services/user_non_working_times/create_service_spec.rb @@ -31,34 +31,36 @@ require "spec_helper" require "services/base_services/behaves_like_create_service" -RSpec.describe UserNonWorkingDays::CreateService do +RSpec.describe UserNonWorkingTimes::CreateService do it_behaves_like "BaseServices create service" do - let(:factory) { :user_non_working_day } + let(:factory) { :user_non_working_time } end subject(:service_call) { described_class.new(user: current_user).call(params) } let(:target_user) { create(:user) } - let(:date) { Date.tomorrow } - let(:params) { { user: target_user, date: } } + let(:start_date) { Date.tomorrow } + let(:end_date) { Date.tomorrow + 4.days } + let(:params) { { user: target_user, start_date:, end_date: } } context "when the current user has the global manage_working_times permission" do let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } - it "creates the non-working day record successfully" do + it "creates the non-working time record successfully" do expect(service_call).to be_success - expect(service_call.result).to be_a(UserNonWorkingDay) + expect(service_call.result).to be_a(UserNonWorkingTime) expect(service_call.result).to be_persisted expect(service_call.result.user).to eq(target_user) - expect(service_call.result.date).to eq(date) + expect(service_call.result.start_date).to eq(start_date) + expect(service_call.result.end_date).to eq(end_date) end end context "when the current user has manage_own_working_times for their own record" do let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } - let(:params) { { user: current_user, date: } } + let(:params) { { user: current_user, start_date:, end_date: } } - it "creates the non-working day record successfully" do + it "creates the non-working time record successfully" do expect(service_call).to be_success expect(service_call.result.user).to eq(current_user) end @@ -79,25 +81,4 @@ RSpec.describe UserNonWorkingDays::CreateService do expect(service_call).to be_failure end end - - context "when a system-wide non-working day exists for the same date" do - let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } - - before { create(:non_working_day, date:) } - - it "is unsuccessful due to the system-wide conflict" do - expect(service_call).to be_failure - expect(service_call.errors[:date]).to include( - I18n.t("activerecord.errors.messages.system_wide_non_working_day_exists") - ) - end - end - - context "when no system-wide non-working day exists for the date" do - let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } - - it "creates the record" do - expect(service_call).to be_success - end - end end diff --git a/spec/services/user_non_working_days/delete_service_spec.rb b/spec/services/user_non_working_times/delete_service_spec.rb similarity index 83% rename from spec/services/user_non_working_days/delete_service_spec.rb rename to spec/services/user_non_working_times/delete_service_spec.rb index 31edc02291c..3ea58086879 100644 --- a/spec/services/user_non_working_days/delete_service_spec.rb +++ b/spec/services/user_non_working_times/delete_service_spec.rb @@ -31,32 +31,32 @@ require "spec_helper" require "services/base_services/behaves_like_delete_service" -RSpec.describe UserNonWorkingDays::DeleteService do +RSpec.describe UserNonWorkingTimes::DeleteService do it_behaves_like "BaseServices delete service" do - let(:factory) { :user_non_working_day } + let(:factory) { :user_non_working_time } end subject(:service_call) { described_class.new(user: current_user, model: non_working_day).call } let(:target_user) { create(:user) } - let(:non_working_day) { create(:user_non_working_day, user: target_user) } + let(:non_working_day) { create(:user_non_working_time, user: target_user) } context "when the current user has the global manage_working_times permission" do let(:current_user) { create(:user, global_permissions: [:manage_working_times]) } it "deletes the record successfully" do expect(service_call).to be_success - expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + expect(UserNonWorkingTime.find_by(id: non_working_day.id)).to be_nil end end context "when the current user has manage_own_working_times and owns the record" do let(:current_user) { create(:user, global_permissions: [:manage_own_working_times]) } - let(:non_working_day) { create(:user_non_working_day, user: current_user) } + let(:non_working_day) { create(:user_non_working_time, user: current_user) } it "deletes the record successfully" do expect(service_call).to be_success - expect(UserNonWorkingDay.find_by(id: non_working_day.id)).to be_nil + expect(UserNonWorkingTime.find_by(id: non_working_day.id)).to be_nil end end @@ -65,7 +65,7 @@ RSpec.describe UserNonWorkingDays::DeleteService do it "is unsuccessful" do expect(service_call).to be_failure - expect(UserNonWorkingDay.find_by(id: non_working_day.id)).not_to be_nil + expect(UserNonWorkingTime.find_by(id: non_working_day.id)).not_to be_nil end end From 95182bfdd9b7775be1540bb87cb484225378f267 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 2 Mar 2026 19:39:58 +0100 Subject: [PATCH 050/435] Implement first view iteration for non working times --- app/components/_index.sass | 1 + .../calendar_component.html.erb | 3 + .../non_working_times/calendar_component.rb | 110 ++++++++++++++++++ .../non_working_times/calendar_component.sass | 17 +++ .../sidebar_component.html.erb | 38 ++++++ .../non_working_times/sidebar_component.rb | 97 +++++++++++++++ .../sub_header_component.html.erb | 17 +++ .../non_working_times/sub_header_component.rb | 61 ++++++++++ frontend/package-lock.json | 21 ++++ frontend/package.json | 1 + .../users/non-working-times.controller.ts | 109 +++++++++++++++++ frontend/src/stimulus/setup.ts | 2 + 12 files changed, 477 insertions(+) create mode 100644 app/components/users/non_working_times/calendar_component.html.erb create mode 100644 app/components/users/non_working_times/calendar_component.rb create mode 100644 app/components/users/non_working_times/calendar_component.sass create mode 100644 app/components/users/non_working_times/sidebar_component.html.erb create mode 100644 app/components/users/non_working_times/sidebar_component.rb create mode 100644 app/components/users/non_working_times/sub_header_component.html.erb create mode 100644 app/components/users/non_working_times/sub_header_component.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts diff --git a/app/components/_index.sass b/app/components/_index.sass index 589d8517fb8..3401f8f417e 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -19,6 +19,7 @@ @import "step_wizard/footer_component" @import "step_wizard/page_layout_component" @import "users/hover_card_component" +@import "users/non_working_times/calendar_component" @import "work_package_relations_tab/index_component" @import "work_package_relations_tab/relation_component" @import "work_package_types/pattern_input" diff --git a/app/components/users/non_working_times/calendar_component.html.erb b/app/components/users/non_working_times/calendar_component.html.erb new file mode 100644 index 00000000000..94c8c076cff --- /dev/null +++ b/app/components/users/non_working_times/calendar_component.html.erb @@ -0,0 +1,3 @@ +<%= flex_layout(data: wrapper_data, classes: "users-non-working-days-calendar-view") do |calendar_page| %> + <% calendar_page.with_row(classes: "op-fc-wrapper", data: { "users--non-working-days-target" => "calendar" }) %> +<% end %> diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb new file mode 100644 index 00000000000..dde2e159366 --- /dev/null +++ b/app/components/users/non_working_times/calendar_component.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingDays + class CalendarComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + options non_working_times: [], + year: Date.current.year + + private + + def wrapper_data + { + "controller" => "users--non-working-days", + "users--non-working-days-events-value" => events_json, + "users--non-working-days-year-value" => year, + "users--non-working-days-locale-value" => I18n.locale, + "users--non-working-days-start-of-week-value" => first_day_of_week + } + end + + def events_json + (global_events + user_events).to_json + end + + def global_events + non_working_times + .grep(NonWorkingDay) + .map do |day| + { date: day.date.iso8601, title: day.name, type: "global" } + end + end + + def user_events + user_days = non_working_times + .grep(UserNonWorkingDay) + .map(&:date) + .sort + + consecutive_ranges(user_days).map do |range| + days = range.count + { + start: range.first.iso8601, + end: (range.last + 1.day).iso8601, + title: I18n.t("label_x_days", count: days), + type: "user" + } + end + end + + # Maps Setting.start_of_week to FullCalendar's firstDay convention. + # Setting: nil=locale default, 1=Monday, 6=Saturday, 7=Sunday + # FullCalendar firstDay: 0=Sunday, 1=Monday, ..., 6=Saturday + # Nil defaults to 1 (Monday) to match Rails/OpenProject convention. + def first_day_of_week + (Setting.start_of_week || 1) % 7 + end + + # Groups a sorted array of dates into consecutive ranges. + def consecutive_ranges(dates) + return [] if dates.empty? + + ranges = [] + current_range = [dates.first] + + dates.drop(1).each do |date| + if date == current_range.last + 1.day + current_range << date + else + ranges << current_range + current_range = [date] + end + end + + ranges << current_range + ranges + end + end + end +end diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass new file mode 100644 index 00000000000..463add9b140 --- /dev/null +++ b/app/components/users/non_working_times/calendar_component.sass @@ -0,0 +1,17 @@ +.users-non-working-days-calendar-view + height: 100% + + .op-fc-wrapper + flex-grow: 1 + + // Global non-working days rendered as FullCalendar background events + .fc-bg-event.non-working-day--global + background-color: var(--color-scale-red-2, #ffcece) + opacity: 0.8 + + // User non-working day ranges rendered as foreground all-day bars + .fc-event.non-working-day--user + background-color: var(--color-scale-blue-5, #0969da) + border-color: var(--color-scale-blue-6, #0550ae) + color: var(--fgColor-onEmphasis, #fff) + border-radius: 3px diff --git a/app/components/users/non_working_times/sidebar_component.html.erb b/app/components/users/non_working_times/sidebar_component.html.erb new file mode 100644 index 00000000000..769e50f5ed4 --- /dev/null +++ b/app/components/users/non_working_times/sidebar_component.html.erb @@ -0,0 +1,38 @@ +<%= render(Primer::Beta::BorderBox.new(mb: 3)) do |box| %> + <% box.with_header do %> + <%= t(:label_non_working_times_with_count, count: total_user_days) %> + <% end %> + + <% user_ranges.each do |range| %> + <% box.with_row(bg: :accent_emphasis, color: :on_emphasis) do %> + <%= range_label(range) %> + <% end %> + <% end %> +<% end %> + +<%= render(Primer::Beta::BorderBox.new) do |box| %> + <% box.with_header do %> + <%= t(:label_non_working_times_summary) %> + <% end %> + + <% box.with_row do %> + <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> + <% row.with_column { t(:label_total_user_non_working_times) } %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold, color: :accent)) { total_user_days.to_s } } %> + <% end %> + <% end %> + + <% box.with_row do %> + <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> + <% row.with_column { t(:label_total_global_non_working_times) } %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold, color: :accent)) { global_day_count.to_s } } %> + <% end %> + <% end %> + + <% box.with_row do %> + <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold)) { t(:label_total_days_off) } } %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold)) { total_days.to_s } } %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb new file mode 100644 index 00000000000..e41a31e4832 --- /dev/null +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingDays + class SidebarComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + options non_working_times: [], + year: Date.current.year + + private + + def user_ranges + user_days = non_working_times + .grep(UserNonWorkingDay) + .map(&:date) + .sort + + consecutive_ranges(user_days) + end + + def global_day_count + non_working_times.count { |d| d.is_a?(NonWorkingDay) } + end + + def total_user_days + user_ranges.sum(&:count) + end + + def total_days + total_user_days + global_day_count + end + + def range_label(range) + count = range.count + "#{format_date_range(range.first, range.last)}: #{I18n.t('label_x_days', count:)}" + end + + def format_date_range(first, last) + if first.month == last.month && first.year == last.year + "#{I18n.l(first, format: '%b %d')}-#{last.day}, #{first.year}" + elsif first.year == last.year + "#{I18n.l(first, format: '%b %d')} - #{I18n.l(last, format: '%b %d')}, #{first.year}" + else + "#{I18n.l(first, format: '%b %d, %Y')} - #{I18n.l(last, format: '%b %d, %Y')}" + end + end + + def consecutive_ranges(dates) + return [] if dates.empty? + + ranges = [] + current_range = [dates.first] + + dates.drop(1).each do |date| + if date == current_range.last + 1.day + current_range << date + else + ranges << current_range + current_range = [date] + end + end + + ranges << current_range + ranges + end + end + end +end diff --git a/app/components/users/non_working_times/sub_header_component.html.erb b/app/components/users/non_working_times/sub_header_component.html.erb new file mode 100644 index 00000000000..061c706002d --- /dev/null +++ b/app/components/users/non_working_times/sub_header_component.html.erb @@ -0,0 +1,17 @@ +<%= render(Primer::OpenProject::SubHeader.new) do |component| + component.with_text { year.to_s } + + component.with_action_button_group do |group| + group.with_button(icon: "arrow-left", tag: :a, **previous_year_attrs) + group.with_button(icon: "arrow-right", tag: :a, **next_year_attrs) + end + + component.with_action_button( + tag: :a, + href: today_href, + leading_icon: :"op-calendar-day", + label: I18n.t(:label_today) + ) do + I18n.t(:label_today_capitalized) + end + end %> diff --git a/app/components/users/non_working_times/sub_header_component.rb b/app/components/users/non_working_times/sub_header_component.rb new file mode 100644 index 00000000000..434df8af472 --- /dev/null +++ b/app/components/users/non_working_times/sub_header_component.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingDays + class SubHeaderComponent < ApplicationComponent + options :year + + def previous_year_attrs + { + href: path_for(year: year - 1), + aria: { label: I18n.t(:label_previous_year) } + } + end + + def next_year_attrs + { + href: path_for(year: year + 1), + aria: { label: I18n.t(:label_next_year) } + } + end + + def today_href + path_for(year: Date.current.year) + end + + private + + def path_for(year:) + url_for(controller: params[:controller], action: params[:action], user_id: params[:user_id], year:) + end + end + end +end diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b13ff351019..47a56a28a4c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "@fullcalendar/list": "^6.1.20", "@fullcalendar/moment": "^6.1.20", "@fullcalendar/moment-timezone": "^6.1.20", + "@fullcalendar/multimonth": "^6.1.20", "@fullcalendar/resource": "^6.1.20", "@fullcalendar/resource-common": "^5.11.5", "@fullcalendar/resource-timeline": "^6.1.20", @@ -5050,6 +5051,18 @@ "moment-timezone": "^0.5.40" } }, + "node_modules/@fullcalendar/multimonth": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/multimonth/-/multimonth-6.1.20.tgz", + "integrity": "sha512-rMMiPBA71lUJ1DV/0ckPtN4/G4LozkkDKoG7/CbmTYqFJiMRskM/1WpilhtRn4iUdNe03V5K7ofFQRs0wo4ZtQ==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, "node_modules/@fullcalendar/premium-common": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz", @@ -28878,6 +28891,14 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/moment-timezone/-/moment-timezone-6.1.20.tgz", "integrity": "sha512-fGk3bQU4hf0rgw3Zd/PH6Ok0Db+s9/nsuALj3IG8GYFqInwLsHZI0Qc+ljN8jv9LrLS5sOBBOZHWDg2ncx1inw==" }, + "@fullcalendar/multimonth": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/multimonth/-/multimonth-6.1.20.tgz", + "integrity": "sha512-rMMiPBA71lUJ1DV/0ckPtN4/G4LozkkDKoG7/CbmTYqFJiMRskM/1WpilhtRn4iUdNe03V5K7ofFQRs0wo4ZtQ==", + "requires": { + "@fullcalendar/daygrid": "~6.1.20" + } + }, "@fullcalendar/premium-common": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz", diff --git a/frontend/package.json b/frontend/package.json index 09dfda7f2a4..0dec547075a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -89,6 +89,7 @@ "@fullcalendar/common": "^5.11.5", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/multimonth": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", "@fullcalendar/list": "^6.1.20", "@fullcalendar/moment": "^6.1.20", diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts new file mode 100644 index 00000000000..c267004d15b --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -0,0 +1,109 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import { Calendar } from '@fullcalendar/core'; +import multiMonthPlugin from '@fullcalendar/multimonth'; +import allLocales from '@fullcalendar/core/locales-all'; + +interface NonWorkingDayEvent { + date?:string; + start?:string; + end?:string; + title:string; + type:'global' | 'user'; +} + +export default class UsersNonWorkingDaysController extends Controller { + static targets = ['calendar']; + + static values = { + events: Array, + year: Number, + locale: String, + startOfWeek: Number, + }; + + declare readonly calendarTarget:HTMLElement; + declare readonly eventsValue:NonWorkingDayEvent[]; + declare readonly yearValue:number; + declare readonly localeValue:string; + declare readonly startOfWeekValue:number; + + private calendar:Calendar; + + connect() { + this.calendar = new Calendar(this.calendarTarget, { + plugins: [multiMonthPlugin], + initialView: 'multiMonthYear', + multiMonthMaxColumns: 1, + locales: allLocales, + locale: this.localeValue, + firstDay: this.startOfWeekValue, + initialDate: `${this.yearValue}-01-01`, + headerToolbar: false, + height: 'auto', + events: this.buildEvents(), + }); + + this.calendar.render(); + + // The stimulus controller gets initialized before the content wrapper is fully shown + // so its height might not be set correctly yet. + setTimeout(() => this.calendar.updateSize(), 25); + } + + disconnect() { + if (this.calendar) { + this.calendar.destroy(); + } + } + + private buildEvents() { + return this.eventsValue.map((event) => { + if (event.type === 'global') { + return { + date: event.date, + title: event.title, + display: 'background', + classNames: ['non-working-day--global'], + }; + } + + return { + start: event.start, + end: event.end, + title: event.title, + classNames: ['non-working-day--user'], + allDay: true, + }; + }); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 074b1a30c4e..ccae21c4c00 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -26,6 +26,7 @@ import EditorController from './controllers/dynamic/work-packages/activities-tab import LazyPageController from './controllers/dynamic/work-packages/activities-tab/lazy-page.controller'; import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; import WorkingHoursFormController from './controllers/dynamic/users/working-hours-form.controller'; +import NonWorkingTimesController from './controllers/dynamic/users/non-working-times.controller'; import AutoSubmit from '@stimulus-components/auto-submit'; import RevealController from '@stimulus-components/reveal'; @@ -84,6 +85,7 @@ OpenProjectStimulusApplication.preregister('highlight-target-element', Highlight OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeController); OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); OpenProjectStimulusApplication.preregister('users--working-hours-form', WorkingHoursFormController); +OpenProjectStimulusApplication.preregister('users--non-working-times', NonWorkingTimesController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); From 0db321fe73d95da3b4c65c19bd8dc7a204a9de03 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 09:54:19 +0100 Subject: [PATCH 051/435] Fix setup for constraint --- db/migrate/20260219152519_add_user_non_working_times.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20260219152519_add_user_non_working_times.rb b/db/migrate/20260219152519_add_user_non_working_times.rb index 971dcaa6a11..72072ed0f71 100644 --- a/db/migrate/20260219152519_add_user_non_working_times.rb +++ b/db/migrate/20260219152519_add_user_non_working_times.rb @@ -14,7 +14,7 @@ class AddUserNonWorkingTimes < ActiveRecord::Migration[8.1] reversible do |direction| direction.up do execute <<~SQL.squish - ALTER TABLE non_working_times + ALTER TABLE user_non_working_times ADD CONSTRAINT no_overlapping_non_working_times EXCLUDE USING gist ( user_id WITH =, From af4080281244f445fc5136660ca8784d3c2f9599 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 10:09:18 +0100 Subject: [PATCH 052/435] Fix class names --- app/components/users/non_working_times/calendar_component.rb | 2 +- app/components/users/non_working_times/sidebar_component.rb | 2 +- app/components/users/non_working_times/sub_header_component.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index dde2e159366..6f1eb648a4b 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -29,7 +29,7 @@ #++ module Users - module NonWorkingDays + module NonWorkingTimes class CalendarComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb index e41a31e4832..be52488b2ad 100644 --- a/app/components/users/non_working_times/sidebar_component.rb +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -29,7 +29,7 @@ #++ module Users - module NonWorkingDays + module NonWorkingTimes class SidebarComponent < ApplicationComponent include OpPrimer::ComponentHelpers diff --git a/app/components/users/non_working_times/sub_header_component.rb b/app/components/users/non_working_times/sub_header_component.rb index 434df8af472..26d6e961794 100644 --- a/app/components/users/non_working_times/sub_header_component.rb +++ b/app/components/users/non_working_times/sub_header_component.rb @@ -29,7 +29,7 @@ #++ module Users - module NonWorkingDays + module NonWorkingTimes class SubHeaderComponent < ApplicationComponent options :year From 6460304e8fca00852f31f107de686abb7b9955b9 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 10:29:27 +0100 Subject: [PATCH 053/435] Correctly implement quick methods to get working days for user --- app/models/user.rb | 15 ++++++++++----- spec/models/user_spec.rb | 38 +++++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index b9da75b0693..a5e58b88ae2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -681,14 +681,19 @@ class User < Principal include Scimitar::Resources::Mixin def non_working_time_entities_for_year(year) - system_days = NonWorkingDay.for_year(year).to_a - user_days = non_working_times.for_year(year).to_a - system_day_dates = system_days.to_set(&:date) - system_days + user_days.reject { |d| system_day_dates.include?(d.date) } + NonWorkingDay.for_year(year).to_a + non_working_times.for_year(year).to_a end def non_working_days_for_year(year) - non_working_time_entities_for_year(year).map(&:date) + working_wdays = Setting.working_days.map { |d| d % 7 } + year_range = Date.new(year, 1, 1)..Date.new(year, 12, 31) + + system_dates = NonWorkingDay.for_year(year).pluck(:date).to_set + user_dates = non_working_times.for_year(year).flat_map do |t| + ([t.start_date, year_range.begin].max..[t.end_date, year_range.end].min).to_a + end.to_set + + (system_dates | user_dates).select { |d| working_wdays.include?(d.wday) } end protected diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bfb037673eb..d895b2638d3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1111,16 +1111,16 @@ RSpec.describe User do end end - describe "#non_working_day_entities_for_year and #non_working_days_for_year" do + describe "#non_working_time_entities_for_year and #non_working_days_for_year" do let(:user) { create(:user) } let(:other_user) { create(:user) } let(:year) { 2025 } let!(:system_nwd) { create(:non_working_day, date: Date.new(year, 12, 25)) } - let!(:user_nwd) { create(:user_non_working_day, user:, date: Date.new(year, 6, 16)) } - let!(:other_user_nwd) { create(:user_non_working_day, user: other_user, date: Date.new(year, 7, 4)) } + let!(:user_nwd) { create(:user_non_working_time, user:, start_date: Date.new(year, 6, 16)) } + let!(:other_user_nwd) { create(:user_non_working_time, user: other_user, start_date: Date.new(year, 7, 4)) } let!(:other_year_system_nwd) { create(:non_working_day, date: Date.new(year - 1, 12, 25)) } - let!(:other_year_user_nwd) { create(:user_non_working_day, user:, date: Date.new(year - 1, 6, 16)) } + let!(:other_year_user_nwd) { create(:user_non_working_time, user:, start_date: Date.new(year - 1, 6, 16)) } describe "#non_working_days_for_year" do subject { user.non_working_days_for_year(year) } @@ -1130,19 +1130,35 @@ RSpec.describe User do end it "includes the user's own non-working days" do - expect(subject).to include(user_nwd.date) + expect(subject).to include(user_nwd.start_date) end it "does not include other users' non-working days" do - expect(subject).not_to include(other_user_nwd.date) + expect(subject).not_to include(other_user_nwd.start_date) end it "does not include dates from other years" do - expect(subject).not_to include(other_year_system_nwd.date, other_year_user_nwd.date) + expect(subject).not_to include(other_year_system_nwd.date, other_year_user_nwd.start_date) + end + + context "when the user non-working time spans multiple days" do + # July 7–13, 2025 is a Monday–Sunday; does not overlap with outer user_nwd (June 16) + let!(:week_nwd) do + create(:user_non_working_time, user:, start_date: Date.new(year, 7, 7), end_date: Date.new(year, 7, 13)) + end + + it "expands the range into individual working days" do + expect(subject).to include(Date.new(year, 7, 7), Date.new(year, 7, 8), Date.new(year, 7, 9), + Date.new(year, 7, 10), Date.new(year, 7, 11)) + end + + it "does not include weekend days within the range" do + expect(subject).not_to include(Date.new(year, 7, 12), Date.new(year, 7, 13)) + end end context "when a user non-working day coincides with a system non-working day" do - let!(:duplicate_user_nwd) { create(:user_non_working_day, user:, date: system_nwd.date) } + let!(:duplicate_user_nwd) { create(:user_non_working_time, user:, start_date: system_nwd.date) } it "returns the date only once" do expect(subject.count { |d| d == system_nwd.date }).to eq(1) @@ -1150,10 +1166,10 @@ RSpec.describe User do end end - describe "#non_working_day_entities_for_year" do - subject { user.non_working_day_entities_for_year(year) } + describe "#non_working_time_entities_for_year" do + subject { user.non_working_time_entities_for_year(year) } - it "returns NonWorkingDay and UserNonWorkingDay records" do + it "returns NonWorkingDay and UserNonWorkingTime records" do expect(subject).to include(system_nwd, user_nwd) end From b4c597a58ca3a7b240c9d1975fd0216c42c3df45 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 10:53:19 +0100 Subject: [PATCH 054/435] Fix more refactoring errors --- .../my/working_times_header_component.rb | 2 +- .../non_working_times/calendar_component.rb | 9 ++++- .../users/non-working-times.controller.ts | 40 ++++++++++++------- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/app/components/my/working_times_header_component.rb b/app/components/my/working_times_header_component.rb index 5435fca9a63..0b18c003b8c 100644 --- a/app/components/my/working_times_header_component.rb +++ b/app/components/my/working_times_header_component.rb @@ -44,7 +44,7 @@ module My end nav.with_tab(selected: params[:action] == "non_working_days", - href: my_non_working_days_path(year: Date.current.year)) do |tab| + href: my_non_working_times_path(year: Date.current.year)) do |tab| tab.with_text { t(:label_non_working_days) } end end diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index 6f1eb648a4b..831220abf46 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -45,10 +45,17 @@ module Users "users--non-working-days-events-value" => events_json, "users--non-working-days-year-value" => year, "users--non-working-days-locale-value" => I18n.locale, - "users--non-working-days-start-of-week-value" => first_day_of_week + "users--non-working-days-start-of-week-value" => first_day_of_week, + "users--non-working-days-working-days-value" => working_days.to_json } end + def working_days + # Setting.working_days is mo=1, tu=2, we=3, th=4, fr=5, sa=6, su=7 + # businessHours in fullcalendar is su=0, mo=1, tu=2, we=3, th=4, fr=5, sa=6 + Setting.working_days.map { |day| day % 7 }.sort + end + def events_json (global_events + user_events).to_json end diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index c267004d15b..7b729e32d1d 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -49,6 +49,7 @@ export default class UsersNonWorkingDaysController extends Controller { year: Number, locale: String, startOfWeek: Number, + workingDays: Array, }; declare readonly calendarTarget:HTMLElement; @@ -56,24 +57,12 @@ export default class UsersNonWorkingDaysController extends Controller { declare readonly yearValue:number; declare readonly localeValue:string; declare readonly startOfWeekValue:number; + declare readonly workingDaysValue:number[]; private calendar:Calendar; connect() { - this.calendar = new Calendar(this.calendarTarget, { - plugins: [multiMonthPlugin], - initialView: 'multiMonthYear', - multiMonthMaxColumns: 1, - locales: allLocales, - locale: this.localeValue, - firstDay: this.startOfWeekValue, - initialDate: `${this.yearValue}-01-01`, - headerToolbar: false, - height: 'auto', - events: this.buildEvents(), - }); - - this.calendar.render(); + this.initializeCalendar(); // The stimulus controller gets initialized before the content wrapper is fully shown // so its height might not be set correctly yet. @@ -86,6 +75,29 @@ export default class UsersNonWorkingDaysController extends Controller { } } + private initializeCalendar() { + this.calendar = new Calendar(this.calendarTarget, { + plugins: [multiMonthPlugin], + initialView: 'multiMonthYear', + multiMonthMaxColumns: 1, + locales: allLocales, + locale: this.localeValue, + firstDay: this.startOfWeekValue, + initialDate: `${this.yearValue}-01-01`, + headerToolbar: false, + events: this.buildEvents(), + nowIndicator: true, + height: '100%', + businessHours: { + daysOfWeek: this.workingDaysValue, + startTime: '00:00', + endTime: '24:00', + }, + }); + + this.calendar.render(); + } + private buildEvents() { return this.eventsValue.map((event) => { if (event.type === 'global') { From a1966ccf96c096bcd455936226888b0b7d31d525 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 11:48:54 +0100 Subject: [PATCH 055/435] Fix more errors after rename, use layout --- .../my/working_times_header_component.rb | 2 +- .../calendar_component.html.erb | 4 +- .../non_working_times/calendar_component.rb | 14 ++--- .../non_working_times/calendar_component.sass | 9 ++- .../non_working_times/sidebar_component.rb | 2 +- .../year_overview_component.rb | 56 +++++++++++++++++++ app/views/my/non_working_times.html.erb | 11 +--- .../users/non_working_times/_list.html.erb | 6 +- .../users/non-working-times.controller.ts | 4 +- 9 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 app/components/users/non_working_times/year_overview_component.rb diff --git a/app/components/my/working_times_header_component.rb b/app/components/my/working_times_header_component.rb index 0b18c003b8c..76f1ba27b22 100644 --- a/app/components/my/working_times_header_component.rb +++ b/app/components/my/working_times_header_component.rb @@ -43,7 +43,7 @@ module My tab.with_text { t(:label_working_hours) } end - nav.with_tab(selected: params[:action] == "non_working_days", + nav.with_tab(selected: params[:action] == "non_working_times", href: my_non_working_times_path(year: Date.current.year)) do |tab| tab.with_text { t(:label_non_working_days) } end diff --git a/app/components/users/non_working_times/calendar_component.html.erb b/app/components/users/non_working_times/calendar_component.html.erb index 94c8c076cff..edfd0da2db4 100644 --- a/app/components/users/non_working_times/calendar_component.html.erb +++ b/app/components/users/non_working_times/calendar_component.html.erb @@ -1,3 +1,3 @@ -<%= flex_layout(data: wrapper_data, classes: "users-non-working-days-calendar-view") do |calendar_page| %> - <% calendar_page.with_row(classes: "op-fc-wrapper", data: { "users--non-working-days-target" => "calendar" }) %> +<%= flex_layout(data: wrapper_data, classes: "users-non-working-times-calendar-view") do |calendar_page| %> + <% calendar_page.with_row(classes: "op-fc-wrapper", data: { "users--non-working-times-target" => "calendar" }) %> <% end %> diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index 831220abf46..5438f847ebd 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -41,12 +41,12 @@ module Users def wrapper_data { - "controller" => "users--non-working-days", - "users--non-working-days-events-value" => events_json, - "users--non-working-days-year-value" => year, - "users--non-working-days-locale-value" => I18n.locale, - "users--non-working-days-start-of-week-value" => first_day_of_week, - "users--non-working-days-working-days-value" => working_days.to_json + "controller" => "users--non-working-times", + "users--non-working-times-events-value" => events_json, + "users--non-working-times-year-value" => year, + "users--non-working-times-locale-value" => I18n.locale, + "users--non-working-times-start-of-week-value" => first_day_of_week, + "users--non-working-times-working-days-value" => working_days.to_json } end @@ -70,7 +70,7 @@ module Users def user_events user_days = non_working_times - .grep(UserNonWorkingDay) + .grep(UserNonWorkingTime) .map(&:date) .sort diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index 463add9b140..73ef05a169d 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -1,4 +1,7 @@ -.users-non-working-days-calendar-view +.users-non-working-times-year-overview + height: 75vh + +.users-non-working-times-calendar-view height: 100% .op-fc-wrapper @@ -15,3 +18,7 @@ border-color: var(--color-scale-blue-6, #0550ae) color: var(--fgColor-onEmphasis, #fff) border-radius: 3px + + .fc-multimonth-daygrid-table + .fc-day + background-color: var(--body-background) !important diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb index be52488b2ad..0b159fd5dd5 100644 --- a/app/components/users/non_working_times/sidebar_component.rb +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -40,7 +40,7 @@ module Users def user_ranges user_days = non_working_times - .grep(UserNonWorkingDay) + .grep(UserNonWorkingTime) .map(&:date) .sort diff --git a/app/components/users/non_working_times/year_overview_component.rb b/app/components/users/non_working_times/year_overview_component.rb new file mode 100644 index 00000000000..c90312a3934 --- /dev/null +++ b/app/components/users/non_working_times/year_overview_component.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingTimes + class YearOverviewComponent < ApplicationComponent + attr_reader :non_working_times, :year + + def initialize(year:, non_working_times:, **) + super(**) + @year = year + @non_working_times = non_working_times + end + + def call + render(Users::NonWorkingTimes::SubHeaderComponent.new(year: year)) + + render(Primer::Alpha::Layout.new(classes: "users-non-working-times-year-overview")) do |layout| + layout.with_main do + render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: non_working_times, year: year)) + end + + layout.with_sidebar(col_placement: :end) do + render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: non_working_times, year: year)) + end + end + end + end + end +end diff --git a/app/views/my/non_working_times.html.erb b/app/views/my/non_working_times.html.erb index 6a6b7d0ecc5..b8a41083f70 100644 --- a/app/views/my/non_working_times.html.erb +++ b/app/views/my/non_working_times.html.erb @@ -1,11 +1,2 @@ <%= render(My::WorkingTimesHeaderComponent.new) %> -<%= render(Users::NonWorkingDays::SubHeaderComponent.new(year: @year)) %> - -<%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> - <% layout.with_column(flex: 1) do %> - <%= render(Users::NonWorkingDays::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> - <% end %> - <% layout.with_column do %> - <%= render(Users::NonWorkingDays::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> - <% end %> -<% end %> +<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times)) %> diff --git a/app/views/users/non_working_times/_list.html.erb b/app/views/users/non_working_times/_list.html.erb index 6ca9210fc75..dd275df41a3 100644 --- a/app/views/users/non_working_times/_list.html.erb +++ b/app/views/users/non_working_times/_list.html.erb @@ -27,13 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Users::NonWorkingDays::SubHeaderComponent.new(year: @year)) %> +<%= render(Users::NonWorkingTimes::SubHeaderComponent.new(year: @year)) %> <%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> <% layout.with_column(flex: 1) do %> - <%= render(Users::NonWorkingDays::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> + <%= render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> <% end %> <% layout.with_column do %> - <%= render(Users::NonWorkingDays::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> + <%= render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> <% end %> <% end %> diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index 7b729e32d1d..396701f80e0 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -41,7 +41,7 @@ interface NonWorkingDayEvent { type:'global' | 'user'; } -export default class UsersNonWorkingDaysController extends Controller { +export default class NonWorkingTimesController extends Controller { static targets = ['calendar']; static values = { @@ -75,7 +75,7 @@ export default class UsersNonWorkingDaysController extends Controller { } } - private initializeCalendar() { + initializeCalendar() { this.calendar = new Calendar(this.calendarTarget, { plugins: [multiMonthPlugin], initialView: 'multiMonthYear', From c9f795db8d3b647905debea03e71ac9c04068b1c Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 14:05:56 +0100 Subject: [PATCH 056/435] Implement calendar with clipping for over-year-border events --- .../non_working_times/calendar_component.rb | 52 ++++++++----------- .../non_working_times/calendar_component.sass | 22 +++++--- .../sidebar_component.html.erb | 42 +++++++-------- .../non_working_times/sidebar_component.rb | 37 +++---------- .../sub_header_component.html.erb | 11 ++++ app/models/user_non_working_time.rb | 37 +++++++++++-- config/locales/en.yml | 12 ++++- .../users/non-working-times.controller.ts | 22 ++++++-- 8 files changed, 137 insertions(+), 98 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index 5438f847ebd..64134d6c365 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -69,19 +69,29 @@ module Users end def user_events - user_days = non_working_times + non_working_times .grep(UserNonWorkingTime) - .map(&:date) - .sort + .map do |nwt| + clipped = nwt.clip_to_year(year) + { + start: clipped.start_date.iso8601, + end: (clipped.end_date + 1.day).iso8601, + title: event_title(clipped), + working_days: clipped.working_days_count, + type: "user" + } + end + end - consecutive_ranges(user_days).map do |range| - days = range.count - { - start: range.first.iso8601, - end: (range.last + 1.day).iso8601, - title: I18n.t("label_x_days", count: days), - type: "user" - } + def event_title(clipped) + base = I18n.t("label_x_working_days_time_off", count: clipped.working_days_count) + + if clipped.continues_from_previous_year + "#{base} (#{I18n.t('label_continued_from_previous_year')})" + elsif clipped.continues_into_next_year + "#{base} (#{I18n.t('label_continues_into_next_year')})" + else + base end end @@ -92,26 +102,6 @@ module Users def first_day_of_week (Setting.start_of_week || 1) % 7 end - - # Groups a sorted array of dates into consecutive ranges. - def consecutive_ranges(dates) - return [] if dates.empty? - - ranges = [] - current_range = [dates.first] - - dates.drop(1).each do |date| - if date == current_range.last + 1.day - current_range << date - else - ranges << current_range - current_range = [date] - end - end - - ranges << current_range - ranges - end end end end diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index 73ef05a169d..7e7b7349f79 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -7,18 +7,28 @@ .op-fc-wrapper flex-grow: 1 + // Rounded corners for the entire calendar + .fc-multiMonthYear-view + border-radius: var(--borderRadius-medium) !important + // Global non-working days rendered as FullCalendar background events .fc-bg-event.non-working-day--global - background-color: var(--color-scale-red-2, #ffcece) - opacity: 0.8 + background-color: var(--bgColor-done-muted) !important + color: var(--fgColor-sponsors) !important + opacity: 0.8 !important + border: 1px solid var(--display-pink-scale-5) !important // User non-working day ranges rendered as foreground all-day bars .fc-event.non-working-day--user - background-color: var(--color-scale-blue-5, #0969da) - border-color: var(--color-scale-blue-6, #0550ae) - color: var(--fgColor-onEmphasis, #fff) - border-radius: 3px + background-color: var(--bgColor-accent-emphasis) !important + border-color: var(--bgColor-accent-emphasis) !important + color: var(--fgColor-onEmphasis) !important + border-radius: var(--borderRadius-medium) !important .fc-multimonth-daygrid-table .fc-day background-color: var(--body-background) !important + + &.fc-day-today + background-color: var(--bgColor-accent-muted) !important + color: var(--fgColor-accent) !important diff --git a/app/components/users/non_working_times/sidebar_component.html.erb b/app/components/users/non_working_times/sidebar_component.html.erb index 769e50f5ed4..590e3696d37 100644 --- a/app/components/users/non_working_times/sidebar_component.html.erb +++ b/app/components/users/non_working_times/sidebar_component.html.erb @@ -1,38 +1,32 @@ -<%= render(Primer::Beta::BorderBox.new(mb: 3)) do |box| %> - <% box.with_header do %> - <%= t(:label_non_working_times_with_count, count: total_user_days) %> +<%= render(Primer::Box.new(border: true, border_radius: 3, p: 3, mb: 3)) do %> + <%= render(Primer::Beta::Heading.new(tag: :h3, mb: 2)) do %> + <%= t(:label_non_working_times_with_count, year:, count: total_user_days) %> <% end %> - <% user_ranges.each do |range| %> - <% box.with_row(bg: :accent_emphasis, color: :on_emphasis) do %> - <%= range_label(range) %> + <% user_non_working_times.each do |nwt| %> + <%= render(Primer::Box.new(bg: :accent_emphasis, color: :on_emphasis, border_radius: 2, p: 2, mb: 1)) do %> + <%= range_label(nwt) %> <% end %> <% end %> <% end %> -<%= render(Primer::Beta::BorderBox.new) do |box| %> - <% box.with_header do %> - <%= t(:label_non_working_times_summary) %> +<%= render(Primer::Box.new(border: true, border_radius: 3, p: 3)) do %> + <%= render(Primer::Beta::Heading.new(tag: :h3, mb: 2)) do %> + <%= t(:label_non_working_times_summary, year:) %> <% end %> - <% box.with_row do %> - <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> - <% row.with_column { t(:label_total_user_non_working_times) } %> - <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold, color: :accent)) { total_user_days.to_s } } %> - <% end %> + <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between, mb: 1)) do |row| %> + <% row.with_column { t(:label_total_user_non_working_times) } %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold, color: :accent)) { total_user_days.to_s } } %> <% end %> - <% box.with_row do %> - <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> - <% row.with_column { t(:label_total_global_non_working_times) } %> - <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold, color: :accent)) { global_day_count.to_s } } %> - <% end %> + <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between, mb: 2)) do |row| %> + <% row.with_column { t(:label_total_global_non_working_days) } %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold, color: :sponsors)) { global_day_count.to_s } } %> <% end %> - <% box.with_row do %> - <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> - <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold)) { t(:label_total_days_off) } } %> - <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold)) { total_days.to_s } } %> - <% end %> + <%= render(Primer::OpenProject::FlexLayout.new(justify_content: :space_between)) do |row| %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold)) { t(:label_total_days_off) } } %> + <% row.with_column { render(Primer::Beta::Text.new(font_weight: :bold)) { total_days.to_s } } %> <% end %> <% end %> diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb index 0b159fd5dd5..a84fcba6ea0 100644 --- a/app/components/users/non_working_times/sidebar_component.rb +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -38,13 +38,11 @@ module Users private - def user_ranges - user_days = non_working_times + def user_non_working_times + non_working_times .grep(UserNonWorkingTime) - .map(&:date) - .sort - - consecutive_ranges(user_days) + .sort_by(&:start_date) + .map { |nwt| nwt.clip_to_year(year) } end def global_day_count @@ -52,16 +50,16 @@ module Users end def total_user_days - user_ranges.sum(&:count) + user_non_working_times.sum(&:working_days_count) end def total_days total_user_days + global_day_count end - def range_label(range) - count = range.count - "#{format_date_range(range.first, range.last)}: #{I18n.t('label_x_days', count:)}" + def range_label(clipped) + date_range = format_date_range(clipped.start_date, clipped.end_date) + "#{date_range}: #{I18n.t('label_x_working_days', count: clipped.working_days_count)}" end def format_date_range(first, last) @@ -73,25 +71,6 @@ module Users "#{I18n.l(first, format: '%b %d, %Y')} - #{I18n.l(last, format: '%b %d, %Y')}" end end - - def consecutive_ranges(dates) - return [] if dates.empty? - - ranges = [] - current_range = [dates.first] - - dates.drop(1).each do |date| - if date == current_range.last + 1.day - current_range << date - else - ranges << current_range - current_range = [date] - end - end - - ranges << current_range - ranges - end end end end diff --git a/app/components/users/non_working_times/sub_header_component.html.erb b/app/components/users/non_working_times/sub_header_component.html.erb index 061c706002d..84d633c3f22 100644 --- a/app/components/users/non_working_times/sub_header_component.html.erb +++ b/app/components/users/non_working_times/sub_header_component.html.erb @@ -14,4 +14,15 @@ ) do I18n.t(:label_today_capitalized) end + + component.with_action_button( + scheme: :primary, + leading_icon: :plus, + label: I18n.t(:button_add_non_working_time), + data: { "turbo-stream" => true }, + tag: :a, + href: "#" + ) do + t(:button_add_non_working_time) + end end %> diff --git a/app/models/user_non_working_time.rb b/app/models/user_non_working_time.rb index 076d4e32b85..6e7dbd1745f 100644 --- a/app/models/user_non_working_time.rb +++ b/app/models/user_non_working_time.rb @@ -29,6 +29,16 @@ #++ class UserNonWorkingTime < ApplicationRecord + ClippedNonWorkingTime = Data.define( + :non_working_time, + :start_date, + :end_date, + :working_days_count, + :continues_from_previous_year, + :continues_into_next_year + ) do + delegate :id, :user, :user_id, to: :non_working_time + end belongs_to :user, inverse_of: :non_working_times validates :start_date, :end_date, presence: true @@ -60,15 +70,36 @@ class UserNonWorkingTime < ApplicationRecord end def working_days - working_wdays = Setting.working_days.map { |d| d % 7 } - system_wide = NonWorkingDay.where(date: days).pluck(:date).to_set - days.select { |date| working_wdays.include?(date.wday) && system_wide.exclude?(date) } + working_days_in(days) end delegate :count, to: :working_days, prefix: true + def clip_to_year(year) + year_start = Date.new(year, 1, 1) + year_end = Date.new(year, 12, 31) + + clipped_start = [start_date, year_start].max + clipped_end = [end_date, year_end].min + + ClippedNonWorkingTime.new( + non_working_time: self, + start_date: clipped_start, + end_date: clipped_end, + working_days_count: working_days_in(clipped_start..clipped_end).count, + continues_from_previous_year: start_date < year_start, + continues_into_next_year: end_date > year_end + ) + end + private + def working_days_in(date_range) + working_wdays = Setting.working_days.map { |d| d % 7 } + system_wide = NonWorkingDay.where(date: date_range).pluck(:date).to_set + date_range.select { |date| working_wdays.include?(date.wday) && system_wide.exclude?(date) } + end + def end_date_not_before_start_date return unless start_date.present? && end_date.present? diff --git a/config/locales/en.yml b/config/locales/en.yml index 872114c6a0a..01ec0914e9f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4206,6 +4206,12 @@ en: label_x_days: one: "1 day" other: "%{count} days" + label_x_working_days: + one: "1 working day" + other: "%{count} working days" + label_x_working_days_time_off: + one: "Time off: 1 working day" + other: "Time off: %{count} working days" label_yesterday: "yesterday" label_zen_mode: "Zen mode" label_role_type: "Type" @@ -4219,7 +4225,11 @@ en: label_non_working_days: "Availability calendar" label_non_working_days_with_count: "Non-working days (%{count})" label_non_working_days_summary: "Summary" - label_total_user_non_working_times: "Total non-working days" + label_continued_from_previous_year: "continued from previous year" + label_continues_into_next_year: "continues into next year" + label_non_working_times_with_count: "%{year} time off (%{count})" + label_non_working_times_summary: "%{year} summary" + label_total_user_non_working_times: "Personal non-working days" label_total_global_non_working_days: "Global non-working days" label_total_days_off: "Total days off" macro_execution_error: "Error executing the macro %{macro_name}" diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index 396701f80e0..b31152c02db 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -39,6 +39,7 @@ interface NonWorkingDayEvent { end?:string; title:string; type:'global' | 'user'; + workingDays?:number; } export default class NonWorkingTimesController extends Controller { @@ -62,11 +63,14 @@ export default class NonWorkingTimesController extends Controller { private calendar:Calendar; connect() { - this.initializeCalendar(); + this.initializeCalendar(); - // The stimulus controller gets initialized before the content wrapper is fully shown - // so its height might not be set correctly yet. - setTimeout(() => this.calendar.updateSize(), 25); + // The stimulus controller gets initialized before the content wrapper is fully shown + // so its height might not be set correctly yet. + setTimeout(() => { + this.calendar.updateSize(); + this.scrollToToday(); + }, 25); } disconnect() { @@ -98,6 +102,15 @@ export default class NonWorkingTimesController extends Controller { this.calendar.render(); } + private scrollToToday() { + if (this.yearValue !== new Date().getFullYear()) return; + + this.calendarTarget + .querySelector('.fc-day-today') + ?.closest('.fc-multimonth-month') + ?.scrollIntoView({ block: 'start' }); + } + private buildEvents() { return this.eventsValue.map((event) => { if (event.type === 'global') { @@ -113,6 +126,7 @@ export default class NonWorkingTimesController extends Controller { start: event.start, end: event.end, title: event.title, + extendedProps: { workingDays: event.workingDays }, classNames: ['non-working-day--user'], allDay: true, }; From d3d693f239ea10c5c5df144f91cc4c2e87685eaa Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 16:07:20 +0100 Subject: [PATCH 057/435] Implement editing and adding of non working times --- .../non_working_times/calendar_component.rb | 12 ++- .../dialog_component.html.erb | 30 ++++++++ .../non_working_times/dialog_component.rb | 60 +++++++++++++++ .../users/non_working_times/form.rb | 74 +++++++++++++++++++ .../non_working_times/form_component.html.erb | 20 +++++ .../users/non_working_times/form_component.rb | 64 ++++++++++++++++ .../sidebar_component.html.erb | 12 ++- .../non_working_times/sidebar_component.rb | 23 ++++-- .../sub_header_component.html.erb | 20 ++--- .../non_working_times/sub_header_component.rb | 10 ++- .../year_overview_component.rb | 11 +-- .../user_non_working_times/base_contract.rb | 5 ++ .../user_non_working_times/create_contract.rb | 3 + .../user_non_working_times/delete_contract.rb | 4 + .../user_non_working_times/update_contract.rb | 37 ++++++++++ .../users/non_working_times_controller.rb | 69 ++++++++++++++--- app/models/user_non_working_time.rb | 3 + .../user_non_working_times/update_service.rb | 34 +++++++++ app/views/my/non_working_times.html.erb | 2 +- .../users/non_working_times/_list.html.erb | 2 +- bin/recreate-database | 26 +++++++ config/locales/en.yml | 4 + config/routes.rb | 6 +- .../non-working-times-form.controller.ts | 62 ++++++++++++++++ .../users/non-working-times.controller.ts | 23 +++++- frontend/src/stimulus/setup.ts | 2 + 26 files changed, 577 insertions(+), 41 deletions(-) create mode 100644 app/components/users/non_working_times/dialog_component.html.erb create mode 100644 app/components/users/non_working_times/dialog_component.rb create mode 100644 app/components/users/non_working_times/form.rb create mode 100644 app/components/users/non_working_times/form_component.html.erb create mode 100644 app/components/users/non_working_times/form_component.rb create mode 100644 app/contracts/user_non_working_times/update_contract.rb create mode 100644 app/services/user_non_working_times/update_service.rb create mode 100755 bin/recreate-database create mode 100644 frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index 64134d6c365..468ba11cafa 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -35,10 +35,15 @@ module Users include OpPrimer::ComponentHelpers options non_working_times: [], - year: Date.current.year + year: Date.current.year, + user: nil private + def can_update? + user.present? && UserNonWorkingTimes::UpdateContract.can_update?(user: User.current, target_user: user) + end + def wrapper_data { "controller" => "users--non-working-times", @@ -78,8 +83,9 @@ module Users end: (clipped.end_date + 1.day).iso8601, title: event_title(clipped), working_days: clipped.working_days_count, - type: "user" - } + type: "user", + edit_url: can_update? ? edit_user_non_working_time_path(user, nwt.id) : nil + }.compact end end diff --git a/app/components/users/non_working_times/dialog_component.html.erb b/app/components/users/non_working_times/dialog_component.html.erb new file mode 100644 index 00000000000..27ba06e7395 --- /dev/null +++ b/app/components/users/non_working_times/dialog_component.html.erb @@ -0,0 +1,30 @@ +<%= component_wrapper do %> + <%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:)) do |dialog| %> + <% dialog.with_body do %> + <%= render(Users::NonWorkingTimes::FormComponent.new(user:, non_working_time:)) %> + <% end %> + + <% dialog.with_footer do %> + <%= render(Primer::Box.new(display: :flex, justify_content: :space_between, flex: 1)) do %> + <%= render(Primer::Box.new) do %> + <% if non_working_time.persisted? && can_delete? %> + <%= render( + Primer::Beta::Button.new( + scheme: :danger, + tag: :a, + href: destroy_url, + data: { turbo_method: :delete, turbo_confirm: t(:text_are_you_sure) } + ) + ) do %> + <%= t(:button_delete) %> + <% end %> + <% end %> + <% end %> + <%= render(Primer::Box.new(display: :flex, gap: 2)) do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) { t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, form: Users::NonWorkingTimes::FormComponent::FORM_ID, type: :submit)) { t(:button_confirm) } %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/non_working_times/dialog_component.rb b/app/components/users/non_working_times/dialog_component.rb new file mode 100644 index 00000000000..ea564345cf1 --- /dev/null +++ b/app/components/users/non_working_times/dialog_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingTimes + class DialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "non-working-time-dialog" + + attr_reader :user, :non_working_time + + def initialize(user:, non_working_time:, **) + super(nil, **) + @user = user + @non_working_time = non_working_time + end + + def title + non_working_time.persisted? ? t(:button_edit_non_working_time) : t(:button_add_non_working_time) + end + + def can_delete? + UserNonWorkingTimes::DeleteContract.can_delete?(user: User.current, target_user: user) + end + + def destroy_url + user_non_working_time_path(user, non_working_time) + end + end + end +end diff --git a/app/components/users/non_working_times/form.rb b/app/components/users/non_working_times/form.rb new file mode 100644 index 00000000000..58a173fcafc --- /dev/null +++ b/app/components/users/non_working_times/form.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingTimes + class Form < ApplicationForm + form do |f| + f.group(layout: :horizontal) do |g| + g.single_date_picker( + name: :start_date, + label: I18n.t(:label_start_date), + required: true, + value: model.start_date&.iso8601, + datepicker_options: { + inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, + data: { + action: "change->users--non-working-times-form#previewWorkingDays" + } + } + ) + + g.single_date_picker( + name: :end_date, + label: I18n.t(:label_end_date), + required: true, + value: model.end_date&.iso8601, + datepicker_options: { + inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, + data: { + action: "change->users--non-working-times-form#previewWorkingDays" + } + } + ) + + g.text_field( + name: :working_days_display, + label: I18n.t(:label_working_days), + disabled: true, + value: model.working_days_count, + datepicker_options: { inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID }, + data: { "users--non-working-times-form-target": "workingDaysInput" } + ) + end + end + end + end +end diff --git a/app/components/users/non_working_times/form_component.html.erb b/app/components/users/non_working_times/form_component.html.erb new file mode 100644 index 00000000000..9e61a2eee66 --- /dev/null +++ b/app/components/users/non_working_times/form_component.html.erb @@ -0,0 +1,20 @@ +<%= component_wrapper do %> + <%= primer_form_with( + model: non_working_time, + url: form_url, + method: form_method, + id: FORM_ID, + data: { + controller: "users--non-working-times-form", + "users--non-working-times-form-preview-url-value" => working_days_preview_url + } + ) do |f| %> + <%= render(Users::NonWorkingTimes::Form.new(f)) %> + + <% if non_working_time.errors.any? %> + <%= render(Primer::Beta::Flash.new(scheme: :danger, mt: 2)) do %> + <%= non_working_time.errors.full_messages.join(", ") %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/non_working_times/form_component.rb b/app/components/users/non_working_times/form_component.rb new file mode 100644 index 00000000000..0ae898fe0e8 --- /dev/null +++ b/app/components/users/non_working_times/form_component.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users + module NonWorkingTimes + class FormComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + FORM_ID = "non-working-time-form" + + attr_reader :user, :non_working_time + + def initialize(user:, non_working_time:, **) + super(nil, **) + @user = user + @non_working_time = non_working_time + end + + def form_url + if non_working_time.persisted? + user_non_working_time_path(user, non_working_time) + else + user_non_working_times_path(user) + end + end + + def form_method + non_working_time.persisted? ? :patch : :post + end + + def working_days_preview_url + working_days_preview_user_non_working_times_path(user) + end + end + end +end diff --git a/app/components/users/non_working_times/sidebar_component.html.erb b/app/components/users/non_working_times/sidebar_component.html.erb index 590e3696d37..34ddeaaaff9 100644 --- a/app/components/users/non_working_times/sidebar_component.html.erb +++ b/app/components/users/non_working_times/sidebar_component.html.erb @@ -4,8 +4,16 @@ <% end %> <% user_non_working_times.each do |nwt| %> - <%= render(Primer::Box.new(bg: :accent_emphasis, color: :on_emphasis, border_radius: 2, p: 2, mb: 1)) do %> - <%= range_label(nwt) %> + <% if can_update? %> + <%= link_to edit_href(nwt), + class: "color-bg-accent-emphasis color-fg-on-emphasis rounded-2 p-2 mb-1 d-block", + data: { controller: "async-dialog" } do %> + <%= range_label(nwt) %> + <% end %> + <% else %> + <%= render(Primer::Box.new(bg: :accent_emphasis, color: :on_emphasis, border_radius: 2, p: 2, mb: 1)) do %> + <%= range_label(nwt) %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb index a84fcba6ea0..21c6687815f 100644 --- a/app/components/users/non_working_times/sidebar_component.rb +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -34,7 +34,8 @@ module Users include OpPrimer::ComponentHelpers options non_working_times: [], - year: Date.current.year + year: Date.current.year, + user: nil private @@ -57,18 +58,28 @@ module Users total_user_days + global_day_count end + def can_update? + user.present? && UserNonWorkingTimes::UpdateContract.can_update?(user: User.current, target_user: user) + end + + def can_delete? + user.present? && UserNonWorkingTimes::DeleteContract.can_delete?(user: User.current, target_user: user) + end + + def edit_href(clipped) + edit_user_non_working_time_path(user, clipped.id) + end + def range_label(clipped) date_range = format_date_range(clipped.start_date, clipped.end_date) "#{date_range}: #{I18n.t('label_x_working_days', count: clipped.working_days_count)}" end def format_date_range(first, last) - if first.month == last.month && first.year == last.year - "#{I18n.l(first, format: '%b %d')}-#{last.day}, #{first.year}" - elsif first.year == last.year - "#{I18n.l(first, format: '%b %d')} - #{I18n.l(last, format: '%b %d')}, #{first.year}" + if first.year == last.year + "#{I18n.l(first, format: :short)} - #{I18n.l(last, format: :short)}, #{first.year}" else - "#{I18n.l(first, format: '%b %d, %Y')} - #{I18n.l(last, format: '%b %d, %Y')}" + "#{I18n.l(first, format: :long)} - #{I18n.l(last, format: :long)}" end end end diff --git a/app/components/users/non_working_times/sub_header_component.html.erb b/app/components/users/non_working_times/sub_header_component.html.erb index 84d633c3f22..e9844a77b76 100644 --- a/app/components/users/non_working_times/sub_header_component.html.erb +++ b/app/components/users/non_working_times/sub_header_component.html.erb @@ -15,14 +15,16 @@ I18n.t(:label_today_capitalized) end - component.with_action_button( - scheme: :primary, - leading_icon: :plus, - label: I18n.t(:button_add_non_working_time), - data: { "turbo-stream" => true }, - tag: :a, - href: "#" - ) do - t(:button_add_non_working_time) + if can_create? + component.with_action_button( + scheme: :primary, + leading_icon: :plus, + label: I18n.t(:button_add_non_working_time), + data: { controller: "async-dialog" }, + tag: :a, + href: new_non_working_time_href + ) do + t(:button_add_non_working_time) + end end end %> diff --git a/app/components/users/non_working_times/sub_header_component.rb b/app/components/users/non_working_times/sub_header_component.rb index 26d6e961794..7dabcfc028b 100644 --- a/app/components/users/non_working_times/sub_header_component.rb +++ b/app/components/users/non_working_times/sub_header_component.rb @@ -31,7 +31,15 @@ module Users module NonWorkingTimes class SubHeaderComponent < ApplicationComponent - options :year + options :year, :user + + def can_create? + UserNonWorkingTimes::CreateContract.can_create?(user: User.current, target_user: user) + end + + def new_non_working_time_href + new_user_non_working_time_path(user) + end def previous_year_attrs { diff --git a/app/components/users/non_working_times/year_overview_component.rb b/app/components/users/non_working_times/year_overview_component.rb index c90312a3934..0062e74fb6f 100644 --- a/app/components/users/non_working_times/year_overview_component.rb +++ b/app/components/users/non_working_times/year_overview_component.rb @@ -31,23 +31,24 @@ module Users module NonWorkingTimes class YearOverviewComponent < ApplicationComponent - attr_reader :non_working_times, :year + attr_reader :non_working_times, :year, :user - def initialize(year:, non_working_times:, **) + def initialize(year:, non_working_times:, user:, **) super(**) @year = year @non_working_times = non_working_times + @user = user end def call - render(Users::NonWorkingTimes::SubHeaderComponent.new(year: year)) + + render(Users::NonWorkingTimes::SubHeaderComponent.new(year:, user:)) + render(Primer::Alpha::Layout.new(classes: "users-non-working-times-year-overview")) do |layout| layout.with_main do - render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: non_working_times, year: year)) + render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: non_working_times, year: year, user:)) end layout.with_sidebar(col_placement: :end) do - render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: non_working_times, year: year)) + render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: non_working_times, year: year, user:)) end end end diff --git a/app/contracts/user_non_working_times/base_contract.rb b/app/contracts/user_non_working_times/base_contract.rb index 56d771b1635..275ac9f39af 100644 --- a/app/contracts/user_non_working_times/base_contract.rb +++ b/app/contracts/user_non_working_times/base_contract.rb @@ -38,6 +38,11 @@ module UserNonWorkingTimes def self.model = ::UserNonWorkingTime + def self.can_manage?(user:, target_user:) + user.allowed_globally?(:manage_working_times) || + (target_user.id == user.id && user.allowed_globally?(:manage_own_working_times)) + end + private def validate_manage_permission diff --git a/app/contracts/user_non_working_times/create_contract.rb b/app/contracts/user_non_working_times/create_contract.rb index b4d3217c31a..f8503d74697 100644 --- a/app/contracts/user_non_working_times/create_contract.rb +++ b/app/contracts/user_non_working_times/create_contract.rb @@ -30,5 +30,8 @@ module UserNonWorkingTimes class CreateContract < BaseContract + def self.can_create?(user:, target_user:) + can_manage?(user:, target_user:) + end end end diff --git a/app/contracts/user_non_working_times/delete_contract.rb b/app/contracts/user_non_working_times/delete_contract.rb index dec25b29076..45cdab6b77f 100644 --- a/app/contracts/user_non_working_times/delete_contract.rb +++ b/app/contracts/user_non_working_times/delete_contract.rb @@ -34,5 +34,9 @@ module UserNonWorkingTimes user.allowed_globally?(:manage_working_times) || (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) } + + def self.can_delete?(user:, target_user:) + BaseContract.can_manage?(user:, target_user:) + end end end diff --git a/app/contracts/user_non_working_times/update_contract.rb b/app/contracts/user_non_working_times/update_contract.rb new file mode 100644 index 00000000000..b04b931197b --- /dev/null +++ b/app/contracts/user_non_working_times/update_contract.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingTimes + class UpdateContract < BaseContract + def self.can_update?(user:, target_user:) + can_manage?(user:, target_user:) + end + end +end diff --git a/app/controllers/users/non_working_times_controller.rb b/app/controllers/users/non_working_times_controller.rb index 8da18c58ba2..861b7f82bd7 100644 --- a/app/controllers/users/non_working_times_controller.rb +++ b/app/controllers/users/non_working_times_controller.rb @@ -30,16 +30,17 @@ class Users::NonWorkingTimesController < ApplicationController include WorkingTimesAuthorization + include OpTurbo::ComponentStream layout "admin" before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :create, :destroy + authorization_checked! :index, :new, :create, :edit, :update, :destroy, :working_days_preview before_action :find_user before_action :authorize_manage_working_times - before_action :find_non_working_time, only: %i[destroy] + before_action :find_non_working_time, only: %i[edit update destroy] def index @year = (params[:year].presence || Date.current.year).to_i @@ -48,32 +49,78 @@ class Users::NonWorkingTimesController < ApplicationController render "users/edit" end + def new + @non_working_time = @user.non_working_times.build + + respond_with_dialog( + Users::NonWorkingTimes::DialogComponent.new(user: @user, non_working_time: @non_working_time) + ) + end + + def edit + respond_with_dialog( + Users::NonWorkingTimes::DialogComponent.new(user: @user, non_working_time: @non_working_time) + ) + end + def create call = UserNonWorkingTimes::CreateService .new(user: current_user) .call(**non_working_time_params, user: @user) if call.success? - flash[:notice] = I18n.t(:notice_successful_create) + close_dialog_via_turbo_stream(Users::NonWorkingTimes::DialogComponent::DIALOG_ID) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + update_via_turbo_stream( + component: Users::NonWorkingTimes::FormComponent.new(user: @user, non_working_time: call.result), + status: :unprocessable_entity + ) end - redirect_to user_non_working_times_path(@user) + respond_with_turbo_streams + end + + def update + call = UserNonWorkingTimes::UpdateService + .new(model: @non_working_time, user: current_user) + .call(**non_working_time_params) + + if call.success? + close_dialog_via_turbo_stream(Users::NonWorkingTimes::DialogComponent::DIALOG_ID) + reload_page_via_turbo_stream + else + update_via_turbo_stream( + component: Users::NonWorkingTimes::FormComponent.new(user: @user, non_working_time: call.result), + status: :unprocessable_entity + ) + end + + respond_with_turbo_streams end def destroy call = UserNonWorkingTimes::DeleteService - .new(model: @user_non_working_time, user: current_user) + .new(model: @non_working_time, user: current_user) .call if call.success? - flash[:notice] = I18n.t(:notice_successful_delete) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + render_error_flash_message_via_turbo_stream(message: call.errors.full_messages.join(", ")) end - redirect_to user_non_working_times_path(@user) + respond_with_turbo_streams + end + + def working_days_preview + start_date = Date.parse(params[:start_date]) + end_date = Date.parse(params[:end_date]) + nwt = @user.non_working_times.build(start_date:, end_date:) + + render json: { working_days: nwt.working_days_count } + rescue ArgumentError, TypeError + head :bad_request end private @@ -85,12 +132,12 @@ class Users::NonWorkingTimesController < ApplicationController end def find_non_working_time - @user_non_working_time = @user.non_working_times.find(params[:id]) + @non_working_time = @user.non_working_times.find(params[:id]) rescue ActiveRecord::RecordNotFound render_404 end def non_working_time_params - params.expect(non_working_time: [:date]) + params.expect(user_non_working_time: %i[start_date end_date]) end end diff --git a/app/models/user_non_working_time.rb b/app/models/user_non_working_time.rb index 6e7dbd1745f..39b939d9853 100644 --- a/app/models/user_non_working_time.rb +++ b/app/models/user_non_working_time.rb @@ -70,6 +70,8 @@ class UserNonWorkingTime < ApplicationRecord end def working_days + return [] if start_date.blank? || end_date.blank? + working_days_in(days) end @@ -108,6 +110,7 @@ class UserNonWorkingTime < ApplicationRecord def no_overlapping_ranges return unless start_date.present? && end_date.present? && user_id.present? + return if end_date < start_date errors.add(:start_date, :overlapping_range) if overlapping_range_exists? end diff --git a/app/services/user_non_working_times/update_service.rb b/app/services/user_non_working_times/update_service.rb new file mode 100644 index 00000000000..d38f924d33d --- /dev/null +++ b/app/services/user_non_working_times/update_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module UserNonWorkingTimes + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/views/my/non_working_times.html.erb b/app/views/my/non_working_times.html.erb index b8a41083f70..dd262a23e53 100644 --- a/app/views/my/non_working_times.html.erb +++ b/app/views/my/non_working_times.html.erb @@ -1,2 +1,2 @@ <%= render(My::WorkingTimesHeaderComponent.new) %> -<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times)) %> +<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times, user: @user)) %> diff --git a/app/views/users/non_working_times/_list.html.erb b/app/views/users/non_working_times/_list.html.erb index dd275df41a3..105511930d1 100644 --- a/app/views/users/non_working_times/_list.html.erb +++ b/app/views/users/non_working_times/_list.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Users::NonWorkingTimes::SubHeaderComponent.new(year: @year)) %> +<%= render(Users::NonWorkingTimes::SubHeaderComponent.new(year: @year, user: @user)) %> <%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> <% layout.with_column(flex: 1) do %> diff --git a/bin/recreate-database b/bin/recreate-database new file mode 100755 index 00000000000..7d57c1f905d --- /dev/null +++ b/bin/recreate-database @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# Deletes bundled javascript assets and rebuilds them. +# Useful for when your frontend doesn't work (jQuery not defined etc.) for seemingly no reason at all. + +die() { yell "$*"; exit 1; } +try() { eval "$@" || die "\n\nFailed to run '$*'"; } + +echo "Dropping database" +try "bundle exec rake db:drop" + +echo "Deleting structure.sql to recreate a fresh DB from migrations" +try "rm -f db/structure.sql" + +echo "Creating database" +try "bundle exec rake db:create" + +echo "Migrating database" +try "bundle exec rake db:migrate" + +echo "Seeding database" +try "bundle exec rake db:seed" + +echo "✔ Done." + + diff --git a/config/locales/en.yml b/config/locales/en.yml index 01ec0914e9f..cafa2825875 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4225,8 +4225,12 @@ en: label_non_working_days: "Availability calendar" label_non_working_days_with_count: "Non-working days (%{count})" label_non_working_days_summary: "Summary" + button_add_non_working_time: "Time off" + button_edit_non_working_time: "Edit time off" label_continued_from_previous_year: "continued from previous year" label_continues_into_next_year: "continues into next year" + label_end_date: "Finish date" + label_working_days: "Working days" label_non_working_times_with_count: "%{year} time off (%{count})" label_non_working_times_summary: "%{year} summary" label_total_user_non_working_times: "Personal non-working days" diff --git a/config/routes.rb b/config/routes.rb index e8fbb37b3e4..69d3c86139d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -921,7 +921,11 @@ Rails.application.routes.draw do resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do resources :memberships, controller: "users/memberships", only: %i[update create destroy] resources :working_hours, controller: "users/working_hours" - resources :non_working_times, controller: "users/non_working_times", only: %i[index create destroy] + resources :non_working_times, controller: "users/non_working_times", only: %i[index new create edit update destroy] do + collection do + get :working_days_preview + end + end collection do get "/invite" => "users/invite#start_dialog" diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts new file mode 100644 index 00000000000..c336105e833 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts @@ -0,0 +1,62 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class NonWorkingTimesFormController extends Controller { + static targets = [ + 'workingDaysInput', + ]; + + static values = { + previewUrl: String, + }; + + declare readonly workingDaysInputTarget:HTMLInputElement; + declare readonly hasWorkingDaysInputTarget:boolean; + declare readonly previewUrlValue:string; + + previewWorkingDays() { + const startDate = (this.element.querySelector('#user_non_working_time_start_date')?.value); + const endDate = (this.element.querySelector('#user_non_working_time_end_date')?.value); + + if (!startDate || !endDate) return; + + void fetch(`${this.previewUrlValue}?start_date=${startDate}&end_date=${endDate}`, { + headers: { Accept: 'application/json' }, + }) + .then((r) => r.json() as Promise<{ working_days:number }>) + .then(({ working_days }) => { + if (this.hasWorkingDaysInputTarget) { + this.workingDaysInputTarget.value = String(working_days); + } + }); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index b31152c02db..4fa13fae815 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -32,6 +32,8 @@ import { Controller } from '@hotwired/stimulus'; import { Calendar } from '@fullcalendar/core'; import multiMonthPlugin from '@fullcalendar/multimonth'; import allLocales from '@fullcalendar/core/locales-all'; +import { renderStreamMessage } from '@hotwired/turbo'; +import { TurboHelpers } from 'core-turbo/helpers'; interface NonWorkingDayEvent { date?:string; @@ -40,6 +42,7 @@ interface NonWorkingDayEvent { title:string; type:'global' | 'user'; workingDays?:number; + edit_url?:string; } export default class NonWorkingTimesController extends Controller { @@ -97,11 +100,29 @@ export default class NonWorkingTimesController extends Controller { startTime: '00:00', endTime: '24:00', }, + eventClick: (info) => { + const editUrl = info.event.extendedProps.editUrl as string | undefined; + if (editUrl) { + info.jsEvent.preventDefault(); + this.openDialog(editUrl); + } + }, }); this.calendar.render(); } + private openDialog(url:string):void { + TurboHelpers.showProgressBar(); + + void fetch(url, { + headers: { Accept: 'text/vnd.turbo-stream.html' }, + }) + .then((response) => response.text()) + .then((html) => { renderStreamMessage(html); }) + .finally(() => { TurboHelpers.hideProgressBar(); }); + } + private scrollToToday() { if (this.yearValue !== new Date().getFullYear()) return; @@ -126,7 +147,7 @@ export default class NonWorkingTimesController extends Controller { start: event.start, end: event.end, title: event.title, - extendedProps: { workingDays: event.workingDays }, + extendedProps: { workingDays: event.workingDays, editUrl: event.edit_url }, classNames: ['non-working-day--user'], allDay: true, }; diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index ccae21c4c00..be2be48ec2d 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -27,6 +27,7 @@ import LazyPageController from './controllers/dynamic/work-packages/activities-t import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; import WorkingHoursFormController from './controllers/dynamic/users/working-hours-form.controller'; import NonWorkingTimesController from './controllers/dynamic/users/non-working-times.controller'; +import NonWorkingTimesFormController from './controllers/dynamic/users/non-working-times-form.controller'; import AutoSubmit from '@stimulus-components/auto-submit'; import RevealController from '@stimulus-components/reveal'; @@ -86,6 +87,7 @@ OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeCont OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); OpenProjectStimulusApplication.preregister('users--working-hours-form', WorkingHoursFormController); OpenProjectStimulusApplication.preregister('users--non-working-times', NonWorkingTimesController); +OpenProjectStimulusApplication.preregister('users--non-working-times-form', NonWorkingTimesFormController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); From 73f7e95a3d02af63ba3905ea2790095a93f0009f Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 16:11:37 +0100 Subject: [PATCH 058/435] Put the form inline in the component --- .../users/non_working_times/form.rb | 74 ------------------- .../non_working_times/form_component.html.erb | 47 ++++++++++-- 2 files changed, 39 insertions(+), 82 deletions(-) delete mode 100644 app/components/users/non_working_times/form.rb diff --git a/app/components/users/non_working_times/form.rb b/app/components/users/non_working_times/form.rb deleted file mode 100644 index 58a173fcafc..00000000000 --- a/app/components/users/non_working_times/form.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Users - module NonWorkingTimes - class Form < ApplicationForm - form do |f| - f.group(layout: :horizontal) do |g| - g.single_date_picker( - name: :start_date, - label: I18n.t(:label_start_date), - required: true, - value: model.start_date&.iso8601, - datepicker_options: { - inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, - data: { - action: "change->users--non-working-times-form#previewWorkingDays" - } - } - ) - - g.single_date_picker( - name: :end_date, - label: I18n.t(:label_end_date), - required: true, - value: model.end_date&.iso8601, - datepicker_options: { - inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, - data: { - action: "change->users--non-working-times-form#previewWorkingDays" - } - } - ) - - g.text_field( - name: :working_days_display, - label: I18n.t(:label_working_days), - disabled: true, - value: model.working_days_count, - datepicker_options: { inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID }, - data: { "users--non-working-times-form-target": "workingDaysInput" } - ) - end - end - end - end -end diff --git a/app/components/users/non_working_times/form_component.html.erb b/app/components/users/non_working_times/form_component.html.erb index 9e61a2eee66..cb2d3b4eb1c 100644 --- a/app/components/users/non_working_times/form_component.html.erb +++ b/app/components/users/non_working_times/form_component.html.erb @@ -8,13 +8,44 @@ controller: "users--non-working-times-form", "users--non-working-times-form-preview-url-value" => working_days_preview_url } - ) do |f| %> - <%= render(Users::NonWorkingTimes::Form.new(f)) %> + ) do |form| + render_inline_form(form) do |f| + f.group(layout: :horizontal) do |g| + g.single_date_picker( + name: :start_date, + label: I18n.t(:label_start_date), + required: true, + value: model.start_date&.iso8601, + datepicker_options: { + inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, + data: { + action: "change->users--non-working-times-form#previewWorkingDays" + } + } + ) - <% if non_working_time.errors.any? %> - <%= render(Primer::Beta::Flash.new(scheme: :danger, mt: 2)) do %> - <%= non_working_time.errors.full_messages.join(", ") %> - <% end %> - <% end %> - <% end %> + g.single_date_picker( + name: :end_date, + label: I18n.t(:label_end_date), + required: true, + value: model.end_date&.iso8601, + datepicker_options: { + inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, + data: { + action: "change->users--non-working-times-form#previewWorkingDays" + } + } + ) + + g.text_field( + name: :working_days_display, + label: I18n.t(:label_working_days), + disabled: true, + value: model.working_days_count, + datepicker_options: { inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID }, + data: { "users--non-working-times-form-target": "workingDaysInput" } + ) + end + end + end %> <% end %> From b6b34a53fb27862b870173f43e37160b0bafd625 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 16:19:52 +0100 Subject: [PATCH 059/435] Remove some redundant code. --- .../users/non_working_times/calendar_component.rb | 3 ++- .../users/non_working_times/sidebar_component.rb | 3 ++- app/contracts/user_non_working_times/base_contract.rb | 9 +-------- app/contracts/user_non_working_times/delete_contract.rb | 5 +---- app/models/user_non_working_time.rb | 8 ++++---- 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index 468ba11cafa..e6892beec22 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -74,10 +74,11 @@ module Users end def user_events + system_dates = non_working_times.grep(NonWorkingDay).to_set(&:date) non_working_times .grep(UserNonWorkingTime) .map do |nwt| - clipped = nwt.clip_to_year(year) + clipped = nwt.clip_to_year(year, system_non_working_dates: system_dates) { start: clipped.start_date.iso8601, end: (clipped.end_date + 1.day).iso8601, diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb index 21c6687815f..fc8618b29dd 100644 --- a/app/components/users/non_working_times/sidebar_component.rb +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -40,10 +40,11 @@ module Users private def user_non_working_times + system_dates = non_working_times.grep(NonWorkingDay).to_set(&:date) non_working_times .grep(UserNonWorkingTime) .sort_by(&:start_date) - .map { |nwt| nwt.clip_to_year(year) } + .map { |nwt| nwt.clip_to_year(year, system_non_working_dates: system_dates) } end def global_day_count diff --git a/app/contracts/user_non_working_times/base_contract.rb b/app/contracts/user_non_working_times/base_contract.rb index 275ac9f39af..13743c8c8ad 100644 --- a/app/contracts/user_non_working_times/base_contract.rb +++ b/app/contracts/user_non_working_times/base_contract.rb @@ -46,14 +46,7 @@ module UserNonWorkingTimes private def validate_manage_permission - unless can_manage_working_times? - errors.add :base, :error_unauthorized - end - end - - def can_manage_working_times? - user.allowed_globally?(:manage_working_times) || - (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) + errors.add(:base, :error_unauthorized) unless self.class.can_manage?(user:, target_user: model.user) end end end diff --git a/app/contracts/user_non_working_times/delete_contract.rb b/app/contracts/user_non_working_times/delete_contract.rb index 45cdab6b77f..5008d157625 100644 --- a/app/contracts/user_non_working_times/delete_contract.rb +++ b/app/contracts/user_non_working_times/delete_contract.rb @@ -30,10 +30,7 @@ module UserNonWorkingTimes class DeleteContract < ::DeleteContract - delete_permission -> { - user.allowed_globally?(:manage_working_times) || - (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) - } + delete_permission -> { BaseContract.can_manage?(user:, target_user: model.user) } def self.can_delete?(user:, target_user:) BaseContract.can_manage?(user:, target_user:) diff --git a/app/models/user_non_working_time.rb b/app/models/user_non_working_time.rb index 39b939d9853..9d9b8569792 100644 --- a/app/models/user_non_working_time.rb +++ b/app/models/user_non_working_time.rb @@ -77,7 +77,7 @@ class UserNonWorkingTime < ApplicationRecord delegate :count, to: :working_days, prefix: true - def clip_to_year(year) + def clip_to_year(year, system_non_working_dates: nil) year_start = Date.new(year, 1, 1) year_end = Date.new(year, 12, 31) @@ -88,7 +88,7 @@ class UserNonWorkingTime < ApplicationRecord non_working_time: self, start_date: clipped_start, end_date: clipped_end, - working_days_count: working_days_in(clipped_start..clipped_end).count, + working_days_count: working_days_in(clipped_start..clipped_end, system_non_working_dates:).count, continues_from_previous_year: start_date < year_start, continues_into_next_year: end_date > year_end ) @@ -96,9 +96,9 @@ class UserNonWorkingTime < ApplicationRecord private - def working_days_in(date_range) + def working_days_in(date_range, system_non_working_dates: nil) working_wdays = Setting.working_days.map { |d| d % 7 } - system_wide = NonWorkingDay.where(date: date_range).pluck(:date).to_set + system_wide = system_non_working_dates || NonWorkingDay.where(date: date_range).pluck(:date).to_set date_range.select { |date| working_wdays.include?(date.wday) && system_wide.exclude?(date) } end From 057a8e9e648efa63e590c584f1dc79f57217eab5 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 16:30:21 +0100 Subject: [PATCH 060/435] Feature specs fro non working times --- spec/features/users/non_working_times_spec.rb | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 spec/features/users/non_working_times_spec.rb diff --git a/spec/features/users/non_working_times_spec.rb b/spec/features/users/non_working_times_spec.rb new file mode 100644 index 00000000000..a69350d7e89 --- /dev/null +++ b/spec/features/users/non_working_times_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "User non-working times", :js, with_flag: { user_working_times: true } do + shared_let(:admin) { create(:admin) } + shared_let(:managed_user) { create(:user) } + + let(:dialog_selector) { "##{Users::NonWorkingTimes::DialogComponent::DIALOG_ID}" } + + def visit_non_working_times(for_user: managed_user, year: 2026) + visit user_non_working_times_path(for_user, year:) + end + + def open_create_dialog + click_on I18n.t(:button_add_non_working_time) + expect(page).to have_css(dialog_selector) + end + + def set_date_in_dialog(field_name, date) + datepicker = Components::BasicDatepicker.new(dialog_selector) + datepicker.open("input[name='non_working_time[#{field_name}]']") + datepicker.set_date(date) + end + + def submit_dialog + within(dialog_selector) { click_on I18n.t(:button_confirm) } + expect(page).to have_no_css(dialog_selector) + end + + def expect_sidebar_entry(text) + expect(page).to have_css("a[data-controller='async-dialog']", text:) + end + + def expect_no_sidebar_entry(text) + expect(page).to have_no_css("a[data-controller='async-dialog']", text:) + end + + current_user { admin } + + describe "creating a non-working time" do + before { visit_non_working_times } + + it "creates a single-day entry" do + open_create_dialog + + set_date_in_dialog(:start_date, Date.new(2026, 3, 10)) + set_date_in_dialog(:end_date, Date.new(2026, 3, 10)) + + submit_dialog + + expect_sidebar_entry("Mar 10") + expect(managed_user.non_working_times.count).to eq(1) + end + + it "creates a multi-day range and shows correct working day count" do + open_create_dialog + + # Monday to Friday = 5 working days + set_date_in_dialog(:start_date, Date.new(2026, 3, 9)) + set_date_in_dialog(:end_date, Date.new(2026, 3, 13)) + + submit_dialog + + expect_sidebar_entry("5 working days") + end + + it "shows a validation error when end date is before start date" do + open_create_dialog + + set_date_in_dialog(:start_date, Date.new(2026, 3, 13)) + set_date_in_dialog(:end_date, Date.new(2026, 3, 9)) + + within(dialog_selector) { click_on I18n.t(:button_confirm) } + + expect(page).to have_css(dialog_selector) + within(dialog_selector) do + expect(page).to have_text(I18n.t("activerecord.errors.models.user_non_working_time.attributes.end_date.not_before_start_date")) + end + end + end + + describe "editing a non-working time" do + shared_let(:non_working_time) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 3, 9), + end_date: Date.new(2026, 3, 11)) + end + + before { visit_non_working_times } + + it "opens the edit dialog when clicking a sidebar entry" do + find("a[data-controller='async-dialog']").click + expect(page).to have_css(dialog_selector) + + within(dialog_selector) do + expect(page).to have_field("non_working_time[start_date]", with: "2026-03-09") + expect(page).to have_field("non_working_time[end_date]", with: "2026-03-11") + end + end + + it "saves updated dates" do + find("a[data-controller='async-dialog']").click + expect(page).to have_css(dialog_selector) + + set_date_in_dialog(:end_date, Date.new(2026, 3, 13)) + submit_dialog + + expect(non_working_time.reload.end_date).to eq(Date.new(2026, 3, 13)) + end + end + + describe "deleting a non-working time" do + shared_let(:non_working_time) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 4, 1), + end_date: Date.new(2026, 4, 3)) + end + + before { visit_non_working_times } + + it "deletes the entry via the delete button in the edit dialog" do + find("a[data-controller='async-dialog']").click + expect(page).to have_css(dialog_selector) + + accept_confirm do + within(dialog_selector) { click_on I18n.t(:button_delete) } + end + + expect(page).to have_no_css(dialog_selector) + expect(UserNonWorkingTime.exists?(non_working_time.id)).to be(false) + end + end + + describe "access control" do + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + + it "can view and manage their own non-working times" do + visit user_non_working_times_path(current_user, year: 2026) + + expect(page).to have_button(I18n.t(:button_add_non_working_time)) + end + + it "is denied access to another user's non-working times" do + visit_non_working_times + expect(page).to have_text(I18n.t(:notice_not_authorized)) + end + end + + context "with manage_working_times permission" do + current_user { create(:user, global_permissions: [:manage_working_times]) } + + shared_let(:other_user_nwt) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 5, 4), + end_date: Date.new(2026, 5, 8)) + end + + before { visit_non_working_times } + + it "can view another user's non-working times page with the add button" do + expect(page).to have_button(I18n.t(:button_add_non_working_time)) + end + + it "can open the edit dialog for another user's entry via the sidebar" do + find("a[data-controller='async-dialog']").click + expect(page).to have_css(dialog_selector) + + within(dialog_selector) do + expect(page).to have_field("non_working_time[start_date]", with: "2026-05-04") + expect(page).to have_button(I18n.t(:button_delete)) + end + end + + it "can create a new entry for another user" do + open_create_dialog + + set_date_in_dialog(:start_date, Date.new(2026, 6, 1)) + set_date_in_dialog(:end_date, Date.new(2026, 6, 5)) + + submit_dialog + + expect(managed_user.non_working_times.count).to eq(2) + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "is denied access" do + visit_non_working_times + expect(page).to have_text(I18n.t(:notice_not_authorized)) + end + end + end +end From cf99be568942a0493a83107ea5b3d26c4d4bce24 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 09:00:21 +0100 Subject: [PATCH 061/435] Fix buttons in dialog footer for non_working_time --- .../dialog_component.html.erb | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/app/components/users/non_working_times/dialog_component.html.erb b/app/components/users/non_working_times/dialog_component.html.erb index 27ba06e7395..fe1fb596418 100644 --- a/app/components/users/non_working_times/dialog_component.html.erb +++ b/app/components/users/non_working_times/dialog_component.html.erb @@ -5,26 +5,21 @@ <% end %> <% dialog.with_footer do %> - <%= render(Primer::Box.new(display: :flex, justify_content: :space_between, flex: 1)) do %> - <%= render(Primer::Box.new) do %> - <% if non_working_time.persisted? && can_delete? %> - <%= render( - Primer::Beta::Button.new( - scheme: :danger, - tag: :a, - href: destroy_url, - data: { turbo_method: :delete, turbo_confirm: t(:text_are_you_sure) } - ) - ) do %> - <%= t(:button_delete) %> - <% end %> - <% end %> - <% end %> - <%= render(Primer::Box.new(display: :flex, gap: 2)) do %> - <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) { t(:button_cancel) } %> - <%= render(Primer::Beta::Button.new(scheme: :primary, form: Users::NonWorkingTimes::FormComponent::FORM_ID, type: :submit)) { t(:button_confirm) } %> + <% if non_working_time.persisted? && can_delete? %> + <%= render( + Primer::Beta::Button.new( + scheme: :danger, + tag: :a, + href: destroy_url, + mr: :auto, + data: { turbo_method: :delete, turbo_confirm: t(:text_are_you_sure) } + ) + ) do %> + <%= t(:button_delete) %> <% end %> <% end %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) { t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, form: Users::NonWorkingTimes::FormComponent::FORM_ID, type: :submit)) { t(:button_confirm) } %> <% end %> <% end %> <% end %> From 396d899dc3df4d401264fceb33f34447d48b79a9 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 09:15:14 +0100 Subject: [PATCH 062/435] Add interaction to calendar --- .../users/non_working_times/calendar_component.rb | 12 +++++++++++- .../non_working_times/calendar_component.sass | 1 + .../users/non_working_times_controller.rb | 6 +++++- .../dynamic/users/non-working-times.controller.ts | 15 ++++++++++++++- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index e6892beec22..c4b8b716e78 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -44,8 +44,12 @@ module Users user.present? && UserNonWorkingTimes::UpdateContract.can_update?(user: User.current, target_user: user) end + def can_create? + user.present? && UserNonWorkingTimes::CreateContract.can_create?(user: User.current, target_user: user) + end + def wrapper_data - { + data = { "controller" => "users--non-working-times", "users--non-working-times-events-value" => events_json, "users--non-working-times-year-value" => year, @@ -53,6 +57,12 @@ module Users "users--non-working-times-start-of-week-value" => first_day_of_week, "users--non-working-times-working-days-value" => working_days.to_json } + + if can_create? + data["users--non-working-times-new-url-value"] = new_user_non_working_time_path(user) + end + + data end def working_days diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index 7e7b7349f79..3cc641a8752 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -24,6 +24,7 @@ border-color: var(--bgColor-accent-emphasis) !important color: var(--fgColor-onEmphasis) !important border-radius: var(--borderRadius-medium) !important + cursor: pointer !important .fc-multimonth-daygrid-table .fc-day diff --git a/app/controllers/users/non_working_times_controller.rb b/app/controllers/users/non_working_times_controller.rb index 861b7f82bd7..c29b715418c 100644 --- a/app/controllers/users/non_working_times_controller.rb +++ b/app/controllers/users/non_working_times_controller.rb @@ -50,7 +50,7 @@ class Users::NonWorkingTimesController < ApplicationController end def new - @non_working_time = @user.non_working_times.build + @non_working_time = @user.non_working_times.build(prefilled_params) respond_with_dialog( Users::NonWorkingTimes::DialogComponent.new(user: @user, non_working_time: @non_working_time) @@ -140,4 +140,8 @@ class Users::NonWorkingTimesController < ApplicationController def non_working_time_params params.expect(user_non_working_time: %i[start_date end_date]) end + + def prefilled_params + params.permit(:start_date, :end_date) + end end diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index 4fa13fae815..695618b15ce 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -30,10 +30,12 @@ import { Controller } from '@hotwired/stimulus'; import { Calendar } from '@fullcalendar/core'; +import interactionPlugin from '@fullcalendar/interaction'; import multiMonthPlugin from '@fullcalendar/multimonth'; import allLocales from '@fullcalendar/core/locales-all'; import { renderStreamMessage } from '@hotwired/turbo'; import { TurboHelpers } from 'core-turbo/helpers'; +import moment from 'moment'; interface NonWorkingDayEvent { date?:string; @@ -54,6 +56,7 @@ export default class NonWorkingTimesController extends Controller { locale: String, startOfWeek: Number, workingDays: Array, + newUrl: String, }; declare readonly calendarTarget:HTMLElement; @@ -62,6 +65,8 @@ export default class NonWorkingTimesController extends Controller { declare readonly localeValue:string; declare readonly startOfWeekValue:number; declare readonly workingDaysValue:number[]; + declare readonly hasNewUrlValue:boolean; + declare readonly newUrlValue:string; private calendar:Calendar; @@ -84,7 +89,7 @@ export default class NonWorkingTimesController extends Controller { initializeCalendar() { this.calendar = new Calendar(this.calendarTarget, { - plugins: [multiMonthPlugin], + plugins: [multiMonthPlugin, interactionPlugin], initialView: 'multiMonthYear', multiMonthMaxColumns: 1, locales: allLocales, @@ -107,6 +112,14 @@ export default class NonWorkingTimesController extends Controller { this.openDialog(editUrl); } }, + selectable: this.hasNewUrlValue, + select: (info) => { + const inclusiveEnd = moment(info.end).subtract(12, 'hours').toDate(); + const endStr = inclusiveEnd.toISOString().slice(0, 10); + const url = `${this.newUrlValue}?start_date=${info.startStr}&end_date=${endStr}`; + this.openDialog(url); + this.calendar.unselect(); + }, }); this.calendar.render(); From 15a41d29579939e94f16ae846f5a6d3a0da4df32 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 10:34:42 +0100 Subject: [PATCH 063/435] Fix specs --- spec/controllers/my_controller_spec.rb | 4 ++-- spec/models/user_spec.rb | 1 + spec/services/user_working_hours/update_service_spec.rb | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/spec/controllers/my_controller_spec.rb b/spec/controllers/my_controller_spec.rb index 689655622f7..bdac1939a24 100644 --- a/spec/controllers/my_controller_spec.rb +++ b/spec/controllers/my_controller_spec.rb @@ -378,10 +378,10 @@ RSpec.describe MyController do expect(response).to render_template "working_hours" end - it "assigns @current_working_hours and @working_hours" do + it "assigns @current_working_hours and @past_working_hours" do subject expect(assigns(:current_working_hours)).to eq(user_working_hours) - expect(assigns(:working_hours)).to eq([user_working_hours]) + expect(assigns(:past_working_hours)).to eq([user_working_hours]) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d895b2638d3..b42abf71c96 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1153,6 +1153,7 @@ RSpec.describe User do end it "does not include weekend days within the range" do + week_with_saturday_and_sunday_as_weekend expect(subject).not_to include(Date.new(year, 7, 12), Date.new(year, 7, 13)) end end diff --git a/spec/services/user_working_hours/update_service_spec.rb b/spec/services/user_working_hours/update_service_spec.rb index f7b93da5282..c8b8be5919d 100644 --- a/spec/services/user_working_hours/update_service_spec.rb +++ b/spec/services/user_working_hours/update_service_spec.rb @@ -60,11 +60,11 @@ RSpec.describe UserWorkingHours::UpdateService do end end - context "when the record has today as valid_from (already in effect)" do + context "when the record has today as valid_from (current schedule)" do let(:working_hours) { create(:user_working_hours, user: target_user, valid_from: Date.current) } - it "is unsuccessful" do - expect(service_call).to be_failure + it "is successful because today's schedule is editable in place" do + expect(service_call).to be_success end end From f2f2ecca4468d9ee336cfa75fb2284138c96d92a Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 13:23:47 +0100 Subject: [PATCH 064/435] Refactor views for user administration to be correctly in the users controller, add feature specs --- .../non_working_times/sub_header_component.rb | 2 +- .../users/non_working_times_controller.rb | 9 +- .../users/working_hours_controller.rb | 16 +- app/controllers/users_controller.rb | 26 ++ app/views/users/_general.html.erb | 2 +- .../users/non_working_times/_list.html.erb | 11 +- config/routes.rb | 4 +- lib/open_project/ui/extensible_tabs.rb | 4 +- spec/features/my/working_times_spec.rb | 210 ++++++++++++++ spec/features/users/non_working_times_spec.rb | 226 +++++++++------ spec/features/users/working_hours_spec.rb | 269 ++++++++++++++++++ spec/support/pages/users/non_working_times.rb | 183 ++++++++++++ spec/support/pages/users/working_hours.rb | 171 +++++++++++ 13 files changed, 1002 insertions(+), 131 deletions(-) create mode 100644 spec/features/my/working_times_spec.rb create mode 100644 spec/features/users/working_hours_spec.rb create mode 100644 spec/support/pages/users/non_working_times.rb create mode 100644 spec/support/pages/users/working_hours.rb diff --git a/app/components/users/non_working_times/sub_header_component.rb b/app/components/users/non_working_times/sub_header_component.rb index 7dabcfc028b..1322aac1782 100644 --- a/app/components/users/non_working_times/sub_header_component.rb +++ b/app/components/users/non_working_times/sub_header_component.rb @@ -62,7 +62,7 @@ module Users private def path_for(year:) - url_for(controller: params[:controller], action: params[:action], user_id: params[:user_id], year:) + url_for(controller: params[:controller], action: params[:action], user_id: params[:user_id], year:, tab: params[:tab]) end end end diff --git a/app/controllers/users/non_working_times_controller.rb b/app/controllers/users/non_working_times_controller.rb index c29b715418c..65edcdcba9b 100644 --- a/app/controllers/users/non_working_times_controller.rb +++ b/app/controllers/users/non_working_times_controller.rb @@ -36,19 +36,12 @@ class Users::NonWorkingTimesController < ApplicationController before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :new, :create, :edit, :update, :destroy, :working_days_preview + authorization_checked! :new, :create, :edit, :update, :destroy, :working_days_preview before_action :find_user before_action :authorize_manage_working_times before_action :find_non_working_time, only: %i[edit update destroy] - def index - @year = (params[:year].presence || Date.current.year).to_i - @non_working_times = @user.non_working_time_entities_for_year(@year) - - render "users/edit" - end - def new @non_working_time = @user.non_working_times.build(prefilled_params) diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb index 528e8e669e8..41ade9ea42f 100644 --- a/app/controllers/users/working_hours_controller.rb +++ b/app/controllers/users/working_hours_controller.rb @@ -36,7 +36,7 @@ class Users::WorkingHoursController < ApplicationController before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :new, :edit, :create, :update, :destroy + authorization_checked! :new, :edit, :create, :update, :destroy before_action :find_user before_action :authorize_manage_working_times @@ -45,20 +45,6 @@ class Users::WorkingHoursController < ApplicationController before_action :authorize_working_hours_edit, only: %i[edit update] before_action :authorize_working_hours_delete, only: %i[destroy] - def index - @current_working_hours = @user.working_hours.current - - @future_working_hours = @user.working_hours.upcoming(Date.current + 1) - - @past_working_hours = if @current_working_hours - @user.working_hours.history_for(@current_working_hours) - else - UserWorkingHours.none - end - - render "users/edit" - end - def new @user_working_hours = if current_context? duplicate_current_working_hours(@user) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4c694dbec9b..66c45903af7 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -30,6 +30,7 @@ class UsersController < ApplicationController include OpTurbo::ComponentStream + include WorkingTimesAuthorization layout "admin" @@ -88,6 +89,8 @@ class UsersController < ApplicationController @membership ||= Member.new @individual_principal = @user @contract = Users::UpdateContract.new(@user, current_user) + + prepare_views_for_tab end def create # rubocop:disable Metrics/AbcSize @@ -353,4 +356,27 @@ class UsersController < ApplicationController login: params[:user][:login] || params[:user][:mail], status: User.statuses[:invited]) end + + def prepare_views_for_tab # rubocop:disable Metrics/AbcSize + if params[:tab] == "non_working_times" + authorize_manage_working_times + check_working_times_feature_flag_is_active + + @year = (params[:year].presence || Date.current.year).to_i + @non_working_times = @user.non_working_time_entities_for_year(@year) + elsif params[:tab] == "working_hours" + authorize_manage_working_times + check_working_times_feature_flag_is_active + + @current_working_hours = @user.working_hours.current + + @future_working_hours = @user.working_hours.upcoming(Date.current + 1) + + @past_working_hours = if @current_working_hours + @user.working_hours.history_for(@current_working_hours) + else + UserWorkingHours.none + end + end + end end diff --git a/app/views/users/_general.html.erb b/app/views/users/_general.html.erb index 3d6f5faef87..d869adf9db5 100644 --- a/app/views/users/_general.html.erb +++ b/app/views/users/_general.html.erb @@ -52,7 +52,7 @@ See COPYRIGHT and LICENSE files for more details. "admin--users-password-auth-selected-value": @user.ldap_auth_source_id.blank? }, as: :user do |f| %> - <%= render partial: "form", locals: { f: f } %> + <%= render partial: "users/form", locals: { f: f } %> <%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %> <% end %> diff --git a/app/views/users/non_working_times/_list.html.erb b/app/views/users/non_working_times/_list.html.erb index 105511930d1..c2d150aad23 100644 --- a/app/views/users/non_working_times/_list.html.erb +++ b/app/views/users/non_working_times/_list.html.erb @@ -27,13 +27,4 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Users::NonWorkingTimes::SubHeaderComponent.new(year: @year, user: @user)) %> - -<%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> - <% layout.with_column(flex: 1) do %> - <%= render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> - <% end %> - <% layout.with_column do %> - <%= render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> - <% end %> -<% end %> +<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times, user: @user)) %> diff --git a/config/routes.rb b/config/routes.rb index 69d3c86139d..2b6a1e3b58d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -920,8 +920,8 @@ Rails.application.routes.draw do resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do resources :memberships, controller: "users/memberships", only: %i[update create destroy] - resources :working_hours, controller: "users/working_hours" - resources :non_working_times, controller: "users/non_working_times", only: %i[index new create edit update destroy] do + resources :working_hours, controller: "users/working_hours", except: [:index] + resources :non_working_times, controller: "users/non_working_times", except: [:index] do collection do get :working_days_preview end diff --git a/lib/open_project/ui/extensible_tabs.rb b/lib/open_project/ui/extensible_tabs.rb index 46461d239ea..7363a462453 100644 --- a/lib/open_project/ui/extensible_tabs.rb +++ b/lib/open_project/ui/extensible_tabs.rb @@ -69,14 +69,14 @@ module OpenProject { name: "working_hours", partial: "users/working_hours/list", - path: ->(params) { user_working_hours_path(params[:user]) }, + path: ->(params) { edit_user_path(params[:user], tab: :working_hours) }, label: :label_working_hours, only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, { name: "non_working_times", partial: "users/non_working_times/list", - path: ->(params) { user_non_working_times_path(params[:user]) }, + path: ->(params) { edit_user_path(params[:user], tab: :non_working_times) }, label: :label_non_working_days, only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, diff --git a/spec/features/my/working_times_spec.rb b/spec/features/my/working_times_spec.rb new file mode 100644 index 00000000000..db0202bd40a --- /dev/null +++ b/spec/features/my/working_times_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "My working times pages", :js, with_flag: { user_working_times: true } do + describe "/my/non_working_times" do + let(:nwt_page) { Pages::Users::NonWorkingTimes.new(year: 2026) } + + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + + it "renders the calendar for the current user" do + nwt_page.visit! + + nwt_page.expect_calendar_rendered + end + + it "makes the calendar selectable (new URL data attribute is present)" do + nwt_page.visit! + + nwt_page.expect_selectable_calendar + end + + it "shows the add button" do + nwt_page.visit! + + nwt_page.expect_add_button + end + + context "when clicking a calendar day" do + it "opens the create dialog pre-filled with that date" do + nwt_page.visit! + + nwt_page.click_calendar_day("2026-04-14") + + nwt_page.expect_dialog_open + nwt_page.expect_dialog_dates(start_date: "2026-04-14", end_date: "2026-04-14") + end + end + + context "when creating a non-working time" do + it "can create an entry for themselves" do + nwt_page.visit! + + nwt_page.open_create_dialog + + nwt_page.set_start_date(Date.new(2026, 7, 6)) + nwt_page.set_end_date(Date.new(2026, 7, 10)) + + nwt_page.confirm_dialog + + expect(current_user.non_working_times.count).to eq(1) + end + end + + context "when editing an existing entry" do + let!(:nwt) do + create(:user_non_working_time, user: current_user, + start_date: Date.new(2026, 8, 3), + end_date: Date.new(2026, 8, 7)) + end + + it "can edit via the sidebar link" do + nwt_page.visit! + + nwt_page.open_edit_dialog_from_sidebar + nwt_page.expect_dialog_start_date("2026-08-03") + end + end + end + + context "with manage_working_times permission" do + current_user { create(:user, global_permissions: [:manage_working_times]) } + + it "renders the calendar with the add button and selectable calendar" do + nwt_page.visit! + + nwt_page.expect_add_button + nwt_page.expect_selectable_calendar + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "renders the page but without the add button or selectable calendar" do + nwt_page.visit! + + nwt_page.expect_calendar_rendered + nwt_page.expect_no_add_button + nwt_page.expect_non_selectable_calendar + end + end + end + + describe "/my/working_hours" do + let(:wh_page) { Pages::Users::WorkingHours.new } + + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + + it "renders the current schedule section" do + wh_page.visit! + + wh_page.expect_current_schedule_section + wh_page.expect_future_section + wh_page.expect_history_section + end + + it "shows the pencil button to manage the current schedule" do + wh_page.visit! + + wh_page.expect_editable_current_schedule + end + + it "shows the add button for future schedules" do + wh_page.visit! + + wh_page.expect_add_future_button + end + + context "when creating a current schedule" do + it "opens the dialog without a valid_from field and creates the record" do + wh_page.visit! + + wh_page.open_current_schedule_dialog + wh_page.expect_dialog_title_current + wh_page.expect_no_valid_from_field + + wh_page.submit_dialog + + expect(current_user.working_hours.current).to be_present + end + end + + context "with an existing current schedule" do + let!(:working_hours) do + create(:user_working_hours, + user: current_user, + valid_from: Date.current, + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + it "shows the correct stats on the current schedule card" do + wh_page.visit! + + wh_page.expect_stats(work_days: 5, weekly_hours: "40h", availability: "100%") + end + + it "opens the edit dialog for the current schedule" do + wh_page.visit! + + wh_page.open_current_schedule_dialog + wh_page.expect_dialog_title_current + end + end + end + + context "with manage_working_times permission" do + current_user { create(:user, global_permissions: [:manage_working_times]) } + + it "renders the working hours page" do + wh_page.visit! + + wh_page.expect_current_schedule_section + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "renders the page but without the edit pencil enabled" do + wh_page.visit! + + wh_page.expect_current_schedule_section + wh_page.expect_not_editable_current_schedule + end + end + end +end diff --git a/spec/features/users/non_working_times_spec.rb b/spec/features/users/non_working_times_spec.rb index a69350d7e89..1989d34bbad 100644 --- a/spec/features/users/non_working_times_spec.rb +++ b/spec/features/users/non_working_times_spec.rb @@ -31,80 +31,50 @@ require "spec_helper" RSpec.describe "User non-working times", :js, with_flag: { user_working_times: true } do - shared_let(:admin) { create(:admin) } + shared_let(:user) { create(:user, global_permissions: %i[manage_user view_all_principals manage_working_times]) } shared_let(:managed_user) { create(:user) } - let(:dialog_selector) { "##{Users::NonWorkingTimes::DialogComponent::DIALOG_ID}" } + let(:nwt_page) { Pages::Users::NonWorkingTimes.new(user: managed_user, year: 2026) } - def visit_non_working_times(for_user: managed_user, year: 2026) - visit user_non_working_times_path(for_user, year:) - end - - def open_create_dialog - click_on I18n.t(:button_add_non_working_time) - expect(page).to have_css(dialog_selector) - end - - def set_date_in_dialog(field_name, date) - datepicker = Components::BasicDatepicker.new(dialog_selector) - datepicker.open("input[name='non_working_time[#{field_name}]']") - datepicker.set_date(date) - end - - def submit_dialog - within(dialog_selector) { click_on I18n.t(:button_confirm) } - expect(page).to have_no_css(dialog_selector) - end - - def expect_sidebar_entry(text) - expect(page).to have_css("a[data-controller='async-dialog']", text:) - end - - def expect_no_sidebar_entry(text) - expect(page).to have_no_css("a[data-controller='async-dialog']", text:) - end - - current_user { admin } + current_user { user } describe "creating a non-working time" do - before { visit_non_working_times } + before { nwt_page.visit! } it "creates a single-day entry" do - open_create_dialog + nwt_page.open_create_dialog - set_date_in_dialog(:start_date, Date.new(2026, 3, 10)) - set_date_in_dialog(:end_date, Date.new(2026, 3, 10)) + nwt_page.set_start_date(Date.new(2026, 3, 10)) + nwt_page.set_end_date(Date.new(2026, 3, 10)) - submit_dialog + nwt_page.confirm_dialog - expect_sidebar_entry("Mar 10") + nwt_page.expect_sidebar_entry("Mar 10") expect(managed_user.non_working_times.count).to eq(1) end it "creates a multi-day range and shows correct working day count" do - open_create_dialog + nwt_page.open_create_dialog # Monday to Friday = 5 working days - set_date_in_dialog(:start_date, Date.new(2026, 3, 9)) - set_date_in_dialog(:end_date, Date.new(2026, 3, 13)) + nwt_page.set_start_date(Date.new(2026, 3, 9)) + nwt_page.set_end_date(Date.new(2026, 3, 13)) - submit_dialog + nwt_page.confirm_dialog - expect_sidebar_entry("5 working days") + nwt_page.expect_sidebar_entry("5 working days") end it "shows a validation error when end date is before start date" do - open_create_dialog + nwt_page.open_create_dialog - set_date_in_dialog(:start_date, Date.new(2026, 3, 13)) - set_date_in_dialog(:end_date, Date.new(2026, 3, 9)) + nwt_page.set_start_date(Date.new(2026, 3, 13)) + nwt_page.set_end_date(Date.new(2026, 3, 9)) - within(dialog_selector) { click_on I18n.t(:button_confirm) } + within(nwt_page.dialog_selector) { click_on I18n.t(:button_confirm) } - expect(page).to have_css(dialog_selector) - within(dialog_selector) do - expect(page).to have_text(I18n.t("activerecord.errors.models.user_non_working_time.attributes.end_date.not_before_start_date")) - end + nwt_page.expect_dialog_open + nwt_page.expect_validation_error(I18n.t("activerecord.errors.messages.not_before_start_date")) end end @@ -115,69 +85,144 @@ RSpec.describe "User non-working times", :js, with_flag: { user_working_times: t end_date: Date.new(2026, 3, 11)) end - before { visit_non_working_times } + before { nwt_page.visit! } it "opens the edit dialog when clicking a sidebar entry" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar - within(dialog_selector) do - expect(page).to have_field("non_working_time[start_date]", with: "2026-03-09") - expect(page).to have_field("non_working_time[end_date]", with: "2026-03-11") - end + nwt_page.expect_dialog_dates(start_date: "2026-03-09", end_date: "2026-03-11") end it "saves updated dates" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar - set_date_in_dialog(:end_date, Date.new(2026, 3, 13)) - submit_dialog + nwt_page.set_end_date(Date.new(2026, 3, 13)) + nwt_page.confirm_dialog expect(non_working_time.reload.end_date).to eq(Date.new(2026, 3, 13)) end end describe "deleting a non-working time" do - shared_let(:non_working_time) do + let!(:non_working_time) do create(:user_non_working_time, user: managed_user, start_date: Date.new(2026, 4, 1), end_date: Date.new(2026, 4, 3)) end - before { visit_non_working_times } + before { nwt_page.visit! } it "deletes the entry via the delete button in the edit dialog" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar + nwt_page.delete_in_dialog - accept_confirm do - within(dialog_selector) { click_on I18n.t(:button_delete) } - end - - expect(page).to have_no_css(dialog_selector) expect(UserNonWorkingTime.exists?(non_working_time.id)).to be(false) end end + describe "calendar interaction" do + before { nwt_page.visit! } + + it "pre-fills start and end date when clicking a single calendar day" do + nwt_page.click_calendar_day("2026-03-10") + + nwt_page.expect_dialog_open + nwt_page.expect_dialog_dates(start_date: "2026-03-10", end_date: "2026-03-10") + end + + it "passes the new URL to the calendar so day selection is enabled" do + nwt_page.expect_selectable_calendar + end + end + + describe "calendar interaction - editing from the calendar event" do + shared_let(:non_working_time) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 3, 9), + end_date: Date.new(2026, 3, 11)) + end + + before { nwt_page.visit! } + + it "opens the edit dialog when clicking a calendar event" do + nwt_page.open_edit_dialog_from_calendar + + nwt_page.expect_dialog_dates(start_date: "2026-03-09", end_date: "2026-03-11") + end + end + + describe "working days count preview" do + before { nwt_page.visit! } + + it "updates the working days count in real time as dates change" do + nwt_page.open_create_dialog + + # Monday to Friday = 5 working days + nwt_page.set_start_date(Date.new(2026, 3, 9)) + nwt_page.set_end_date(Date.new(2026, 3, 13)) + + nwt_page.expect_working_days_count(5) + end + end + + describe "overlap validation" do + shared_let(:existing_nwt) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 3, 9), + end_date: Date.new(2026, 3, 15)) + end + + before { nwt_page.visit! } + + it "shows a validation error when the new range overlaps an existing entry" do + nwt_page.open_create_dialog + + nwt_page.set_start_date(Date.new(2026, 3, 12)) + nwt_page.set_end_date(Date.new(2026, 3, 20)) + + within(nwt_page.dialog_selector) { click_on I18n.t(:button_confirm) } + + nwt_page.expect_dialog_open + nwt_page.expect_validation_error(I18n.t("activerecord.errors.messages.overlapping_range")) + end + end + + describe "global non-working days exclusion" do + shared_let(:holiday) do + create(:non_working_day, date: Date.new(2026, 3, 11)) # Wednesday + end + + before { nwt_page.visit! } + + it "excludes system non-working days from the working day count preview" do + nwt_page.open_create_dialog + + # Mon Mar 9 to Fri Mar 13 would be 5 days, but Wed Mar 11 is a system holiday + nwt_page.set_start_date(Date.new(2026, 3, 9)) + nwt_page.set_end_date(Date.new(2026, 3, 13)) + + nwt_page.expect_working_days_count(4) + end + end + describe "access control" do context "with manage_own_working_times permission" do current_user { create(:user, global_permissions: [:manage_own_working_times]) } + let(:nwt_page) { Pages::Users::NonWorkingTimes.new(user: current_user, year: 2026) } - it "can view and manage their own non-working times" do - visit user_non_working_times_path(current_user, year: 2026) - - expect(page).to have_button(I18n.t(:button_add_non_working_time)) + it "is denied access to their own non-working times on the users page" do + nwt_page.visit! + nwt_page.expect_not_authorized end it "is denied access to another user's non-working times" do - visit_non_working_times - expect(page).to have_text(I18n.t(:notice_not_authorized)) + Pages::Users::NonWorkingTimes.new(user: managed_user, year: 2026).visit! + nwt_page.expect_not_authorized end end - context "with manage_working_times permission" do - current_user { create(:user, global_permissions: [:manage_working_times]) } + context "with manage_user, view_all_principals, and manage_working_times permissions" do + current_user { create(:user, global_permissions: %i[manage_user view_all_principals manage_working_times]) } shared_let(:other_user_nwt) do create(:user_non_working_time, user: managed_user, @@ -185,29 +230,26 @@ RSpec.describe "User non-working times", :js, with_flag: { user_working_times: t end_date: Date.new(2026, 5, 8)) end - before { visit_non_working_times } + before { nwt_page.visit! } it "can view another user's non-working times page with the add button" do - expect(page).to have_button(I18n.t(:button_add_non_working_time)) + nwt_page.expect_add_button end it "can open the edit dialog for another user's entry via the sidebar" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar - within(dialog_selector) do - expect(page).to have_field("non_working_time[start_date]", with: "2026-05-04") - expect(page).to have_button(I18n.t(:button_delete)) - end + nwt_page.expect_dialog_start_date("2026-05-04") + nwt_page.expect_dialog_has_delete_button end it "can create a new entry for another user" do - open_create_dialog + nwt_page.open_create_dialog - set_date_in_dialog(:start_date, Date.new(2026, 6, 1)) - set_date_in_dialog(:end_date, Date.new(2026, 6, 5)) + nwt_page.set_start_date(Date.new(2026, 6, 1)) + nwt_page.set_end_date(Date.new(2026, 6, 5)) - submit_dialog + nwt_page.confirm_dialog expect(managed_user.non_working_times.count).to eq(2) end @@ -217,8 +259,8 @@ RSpec.describe "User non-working times", :js, with_flag: { user_working_times: t current_user { create(:user) } it "is denied access" do - visit_non_working_times - expect(page).to have_text(I18n.t(:notice_not_authorized)) + nwt_page.visit! + nwt_page.expect_not_authorized end end end diff --git a/spec/features/users/working_hours_spec.rb b/spec/features/users/working_hours_spec.rb new file mode 100644 index 00000000000..4e95d47407f --- /dev/null +++ b/spec/features/users/working_hours_spec.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "User working hours", :js, with_flag: { user_working_times: true } do + shared_let(:admin) { create(:admin) } + shared_let(:managed_user) { create(:user) } + + let(:wh_page) { Pages::Users::WorkingHours.new(user: managed_user) } + + current_user { admin } + + describe "current schedule card" do + context "when no working hours exist" do + before { wh_page.visit! } + + it "shows the not-set placeholder text in the stats" do + wh_page.expect_current_schedule_section + wh_page.expect_not_set + end + + it "shows the edit pencil linked to the create dialog" do + wh_page.expect_editable_current_schedule + end + end + + context "when working hours are set for today" do + shared_let(:working_hours) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.current, + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 80) + end + + before { wh_page.visit! } + + it "displays the correct work days, hours, availability, and effective hours" do + wh_page.expect_stats( + work_days: 5, + weekly_hours: "40h", + availability: "80%", + effective_hours: "32h" + ) + end + + it "shows the pencil linked to the edit dialog" do + wh_page.open_current_schedule_dialog + + wh_page.expect_dialog_title_current + end + end + end + + describe "creating a current schedule" do + before { wh_page.visit! } + + it "creates working hours via the current schedule dialog" do + wh_page.open_current_schedule_dialog + + wh_page.expect_dialog_title_current + wh_page.expect_no_valid_from_field + + wh_page.submit_dialog + + expect(managed_user.working_hours.count).to eq(1) + expect(managed_user.working_hours.current.valid_from).to eq(Date.current) + end + end + + describe "editing the current schedule" do + shared_let(:working_hours) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.current, + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + before { wh_page.visit! } + + it "opens the edit dialog with the current schedule's title" do + wh_page.open_current_schedule_dialog + + wh_page.expect_dialog_title_current + wh_page.expect_no_valid_from_field + end + + it "saves changes to the current schedule" do + wh_page.open_current_schedule_dialog + wh_page.set_availability_factor(75) + wh_page.save_dialog + + expect(working_hours.reload.availability_factor).to eq(75) + end + end + + describe "future schedules" do + describe "adding a future schedule" do + before { wh_page.visit! } + + it "shows the future schedule section with an add button" do + wh_page.expect_future_section + wh_page.expect_add_future_button + end + + it "shows the blank slate when no future schedules exist" do + wh_page.expect_future_blank_slate + end + + it "creates a future schedule via the dialog" do + wh_page.open_add_future_schedule_dialog + + wh_page.expect_dialog_title_future + wh_page.expect_valid_from_field + + wh_page.set_valid_from(Date.new(2027, 1, 1)) + + wh_page.submit_dialog + + expect(managed_user.working_hours.upcoming(Date.new(2027, 1, 1)).count).to eq(1) + end + end + + describe "editing a future schedule" do + shared_let(:future_wh) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2027, 6, 1), + monday: 240, tuesday: 240, wednesday: 240, thursday: 240, friday: 240, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + before { wh_page.visit! } + + it "opens the edit dialog from the action menu" do + wh_page.open_row_action_menu + click_on I18n.t(:button_edit) + + expect(page).to have_css(wh_page.dialog_selector) + wh_page.expect_dialog_title_future + end + + it "saves updated values" do + wh_page.open_row_action_menu + click_on I18n.t(:button_edit) + + wh_page.set_availability_factor(50) + wh_page.save_dialog + + expect(future_wh.reload.availability_factor).to eq(50) + end + end + + describe "deleting a future schedule" do + it "deletes the schedule via the action menu" do + future_wh = create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2027, 6, 1), + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + + wh_page.visit! + + wh_page.open_row_action_menu + wh_page.delete_schedule + + expect(UserWorkingHours.exists?(future_wh.id)).to be(false) + end + end + end + + describe "schedule history" do + shared_let(:past_wh) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2025, 1, 1), + monday: 360, tuesday: 360, wednesday: 360, thursday: 360, friday: 360, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + shared_let(:current_wh) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2026, 1, 1), + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + before { wh_page.visit! } + + it "shows past schedules in the history section" do + wh_page.expect_history_section + # The 2025 entry has 6h/day × 5 days = 30h/week + expect(page).to have_text("30h") + end + end + + describe "access control" do + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + let(:wh_page) { Pages::Users::WorkingHours.new(user: current_user) } + + it "is denied access to their own working hours on the users page" do + wh_page.visit! + wh_page.expect_not_authorized + end + + it "is denied access to another user's working hours" do + Pages::Users::WorkingHours.new(user: managed_user).visit! + wh_page.expect_not_authorized + end + end + + context "with manage_user, view_all_principals, and manage_working_times permissions" do + current_user { create(:user, global_permissions: %i[manage_user view_all_principals manage_working_times]) } + + it "can view another user's working hours page" do + wh_page.visit! + + wh_page.expect_current_schedule_section + wh_page.expect_editable_current_schedule + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "is denied access" do + wh_page.visit! + wh_page.expect_not_authorized + end + end + end +end diff --git a/spec/support/pages/users/non_working_times.rb b/spec/support/pages/users/non_working_times.rb new file mode 100644 index 00000000000..29fa5bf748a --- /dev/null +++ b/spec/support/pages/users/non_working_times.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "support/pages/page" + +module Pages + module Users + class NonWorkingTimes < ::Pages::Page + attr_reader :user, :year + + # Pass user: nil for the /my/non_working_times context + def initialize(user: nil, year: Date.current.year) + super() + @user = user + @year = year + end + + def path + if user + edit_user_path(user, tab: :non_working_times, year:) + else + my_non_working_times_path(year:) + end + end + + def dialog_selector + "##{::Users::NonWorkingTimes::DialogComponent::DIALOG_ID}" + end + + # -- Actions -- + + def open_create_dialog + click_on I18n.t(:button_add_non_working_time) + expect(page).to have_css(dialog_selector) + end + + def open_edit_dialog_from_sidebar + find("a[data-controller='async-dialog'][href*='/edit']").click + expect(page).to have_css(dialog_selector) + end + + def open_edit_dialog_from_calendar + find(".non-working-day--user").click + expect(page).to have_css(dialog_selector) + end + + def click_calendar_day(date) + find("[data-date='#{date}']").click + end + + def set_start_date(date) + set_date_field(:start_date, date) + end + + def set_end_date(date) + set_date_field(:end_date, date) + end + + def confirm_dialog + within(dialog_selector) { click_on I18n.t(:button_confirm) } + expect(page).to have_no_css(dialog_selector) + end + + def delete_in_dialog + accept_confirm do + within(dialog_selector) { click_on I18n.t(:button_delete) } + end + expect(page).to have_no_css(dialog_selector) + end + + # -- Expectations -- + + def expect_dialog_open + expect(page).to have_css(dialog_selector) + end + + def expect_dialog_closed + expect(page).to have_no_css(dialog_selector) + end + + def expect_dialog_start_date(value) + within(dialog_selector) do + expect(page).to have_field("user_non_working_time[start_date]", with: value) + end + end + + def expect_dialog_end_date(value) + within(dialog_selector) do + expect(page).to have_field("user_non_working_time[end_date]", with: value) + end + end + + def expect_dialog_dates(start_date:, end_date:) + expect_dialog_start_date(start_date) + expect_dialog_end_date(end_date) + end + + def expect_dialog_has_delete_button + within(dialog_selector) do + expect(page).to have_link(I18n.t(:button_delete)) + end + end + + def expect_validation_error(message) + within(dialog_selector) do + expect(page).to have_text(message) + end + end + + def expect_working_days_count(count) + expect(page).to have_field(I18n.t(:label_working_days), disabled: true, with: count.to_s) + end + + def expect_sidebar_entry(text) + expect(page).to have_css("a[data-controller='async-dialog']", text:) + end + + def expect_no_sidebar_entry(text) + expect(page).to have_no_css("a[data-controller='async-dialog']", text:) + end + + def expect_add_button + expect(page).to have_link(I18n.t(:button_add_non_working_time)) + end + + def expect_no_add_button + expect(page).to have_no_link(I18n.t(:button_add_non_working_time)) + end + + def expect_selectable_calendar + expect(page).to have_css("[data-users--non-working-times-new-url-value]") + end + + def expect_non_selectable_calendar + expect(page).to have_no_css("[data-users--non-working-times-new-url-value]") + end + + def expect_calendar_rendered + expect(page).to have_css(".op-fc-wrapper") + expect(page).to have_css(".users-non-working-times-calendar-view") + end + + def expect_not_authorized + expect(page).to have_text(I18n.t(:notice_not_authorized)) + end + + private + + def set_date_field(field_name, date) + datepicker = Components::BasicDatepicker.new(dialog_selector) + datepicker.open("input[name='user_non_working_time[#{field_name}]']") + datepicker.set_date(date) + end + end + end +end diff --git a/spec/support/pages/users/working_hours.rb b/spec/support/pages/users/working_hours.rb new file mode 100644 index 00000000000..24dce02c8cc --- /dev/null +++ b/spec/support/pages/users/working_hours.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "support/pages/page" + +module Pages + module Users + class WorkingHours < ::Pages::Page + attr_reader :user + + # Pass user: nil for the /my/working_hours context + def initialize(user: nil) + super() + @user = user + end + + def path + if user + edit_user_path(user, tab: :working_hours) + else + my_working_hours_path + end + end + + def dialog_selector + "##{::Users::WorkingHours::DialogComponent::DIALOG_ID}" + end + + # -- Actions -- + + def open_current_schedule_dialog + find("a[data-controller='async-dialog'][href*='current']").click + expect(page).to have_css(dialog_selector) + end + + def open_add_future_schedule_dialog + first("a[data-controller='async-dialog'][href$='working_hours/new']").click + expect(page).to have_css(dialog_selector) + end + + def open_row_action_menu + find(:link_or_button) { it.has_selector?("svg.octicon-kebab-horizontal") }.click + end + + def set_valid_from(date) + datepicker = Components::BasicDatepicker.new(dialog_selector) + datepicker.open("input[name='user_working_hours[valid_from]']") + datepicker.set_date(date) + end + + def set_availability_factor(value) + within(dialog_selector) do + fill_in "user_working_hours[availability_factor]", with: value.to_s + end + end + + def submit_dialog + within(dialog_selector) { click_on I18n.t(:button_create) } + expect(page).to have_no_css(dialog_selector) + end + + def save_dialog + within(dialog_selector) { click_on I18n.t(:button_save) } + expect(page).to have_no_css(dialog_selector) + end + + def delete_schedule + accept_confirm do + click_on I18n.t(:button_delete) + end + expect(page).to have_no_css(dialog_selector) + end + + # -- Expectations -- + + def expect_current_schedule_section + expect(page).to have_text(I18n.t("users.working_hours.current_schedule.title")) + end + + def expect_future_section + expect(page).to have_text(I18n.t("users.working_hours.future.title")) + end + + def expect_history_section + expect(page).to have_text(I18n.t("users.working_hours.history.title")) + end + + def expect_not_set + expect(page).to have_text(I18n.t("users.working_hours.current_schedule.not_set"), minimum: 1) + end + + def expect_stats(work_days: nil, weekly_hours: nil, availability: nil, effective_hours: nil) + expect(page).to have_text(work_days.to_s) if work_days + expect(page).to have_text(weekly_hours) if weekly_hours + expect(page).to have_text(availability) if availability + expect(page).to have_text(effective_hours) if effective_hours + end + + def expect_future_blank_slate + expect(page).to have_text(I18n.t("users.working_hours.future.blank_title")) + end + + def expect_editable_current_schedule + expect(page).to have_css("a[data-controller='async-dialog'][href*='current']") + end + + def expect_not_editable_current_schedule + expect(page).to have_no_css("a[data-controller='async-dialog'][href*='current']") + end + + def expect_add_future_button + expect(page).to have_css("a[data-controller='async-dialog'][href$='working_hours/new']") + end + + def expect_dialog_title_current + within(dialog_selector) do + expect(page).to have_text(I18n.t("users.working_hours.form.title_current")) + end + end + + def expect_dialog_title_future + within(dialog_selector) do + expect(page).to have_text(I18n.t("users.working_hours.form.title")) + end + end + + def expect_no_valid_from_field + within(dialog_selector) do + expect(page).to have_no_field(I18n.t("users.working_hours.form.start_date")) + end + end + + def expect_valid_from_field + within(dialog_selector) do + expect(page).to have_field(I18n.t("users.working_hours.form.start_date")) + end + end + + def expect_not_authorized + expect(page).to have_text(I18n.t(:notice_not_authorized)) + end + end + end +end From 0902b6de44a418f86926ff1129a356446696bee1 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 13:36:04 +0100 Subject: [PATCH 065/435] Add specs for the deletion of our new models --- .../principals/delete_job_integration_spec.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/workers/principals/delete_job_integration_spec.rb b/spec/workers/principals/delete_job_integration_spec.rb index bd92b2ea1e4..042b90c3dad 100644 --- a/spec/workers/principals/delete_job_integration_spec.rb +++ b/spec/workers/principals/delete_job_integration_spec.rb @@ -318,6 +318,26 @@ RSpec.describe Principals::DeleteJob, type: :model do end end + shared_examples_for "working hours handling" do + let!(:working_hours) { create(:user_working_hours, user: principal) } + + it "removes the working hours" do + job + + expect(UserWorkingHours.find_by(id: working_hours.id)).to be_nil + end + end + + shared_examples_for "non working times handling" do + let!(:non_working_time) { create(:user_non_working_time, user: principal) } + + it "removes the non working times" do + job + + expect(UserNonWorkingTime.find_by(id: non_working_time.id)).to be_nil + end + end + shared_examples_for "public cost_query handling" do let!(:query) { create(:public_cost_query, user: principal) } @@ -472,6 +492,8 @@ RSpec.describe Principals::DeleteJob, type: :model do it_behaves_like "cost_query handling" it_behaves_like "project query handling" it_behaves_like "mention rewriting" + it_behaves_like "working hours handling" + it_behaves_like "non working times handling" describe "favorites" do before do From 4854b1ecab634a5aad61bb6634e9fd685bf9c127 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 13:39:39 +0100 Subject: [PATCH 066/435] Fix rubocop issues --- .../non_working_times/calendar_component.rb | 24 ++++++++++--------- .../year_overview_component.rb | 19 +++++++++------ app/models/user.rb | 17 +++++++++---- .../my/time_tracking/calendar_component.rb | 2 +- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index c4b8b716e78..6924d8ca0a8 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -87,17 +87,19 @@ module Users system_dates = non_working_times.grep(NonWorkingDay).to_set(&:date) non_working_times .grep(UserNonWorkingTime) - .map do |nwt| - clipped = nwt.clip_to_year(year, system_non_working_dates: system_dates) - { - start: clipped.start_date.iso8601, - end: (clipped.end_date + 1.day).iso8601, - title: event_title(clipped), - working_days: clipped.working_days_count, - type: "user", - edit_url: can_update? ? edit_user_non_working_time_path(user, nwt.id) : nil - }.compact - end + .map { |nwt| user_event_for(nwt, system_dates) } + end + + def user_event_for(nwt, system_dates) + clipped = nwt.clip_to_year(year, system_non_working_dates: system_dates) + { + start: clipped.start_date.iso8601, + end: (clipped.end_date + 1.day).iso8601, + title: event_title(clipped), + working_days: clipped.working_days_count, + type: "user", + edit_url: can_update? ? edit_user_non_working_time_path(user, nwt.id) : nil + }.compact end def event_title(clipped) diff --git a/app/components/users/non_working_times/year_overview_component.rb b/app/components/users/non_working_times/year_overview_component.rb index 0062e74fb6f..23a5f7f034f 100644 --- a/app/components/users/non_working_times/year_overview_component.rb +++ b/app/components/users/non_working_times/year_overview_component.rb @@ -43,15 +43,20 @@ module Users def call render(Users::NonWorkingTimes::SubHeaderComponent.new(year:, user:)) + render(Primer::Alpha::Layout.new(classes: "users-non-working-times-year-overview")) do |layout| - layout.with_main do - render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: non_working_times, year: year, user:)) - end - - layout.with_sidebar(col_placement: :end) do - render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: non_working_times, year: year, user:)) - end + layout.with_main { render_calendar } + layout.with_sidebar(col_placement: :end) { render_sidebar } end end + + private + + def render_calendar + render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times:, year:, user:)) + end + + def render_sidebar + render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times:, year:, user:)) + end end end end diff --git a/app/models/user.rb b/app/models/user.rb index a5e58b88ae2..707eeaa0b26 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -686,14 +686,21 @@ class User < Principal def non_working_days_for_year(year) working_wdays = Setting.working_days.map { |d| d % 7 } - year_range = Date.new(year, 1, 1)..Date.new(year, 12, 31) + all_dates = system_non_working_dates_for_year(year) | user_non_working_dates_for_year(year) + all_dates.select { |d| working_wdays.include?(d.wday) } + end - system_dates = NonWorkingDay.for_year(year).pluck(:date).to_set - user_dates = non_working_times.for_year(year).flat_map do |t| + private + + def system_non_working_dates_for_year(year) + NonWorkingDay.for_year(year).pluck(:date).to_set + end + + def user_non_working_dates_for_year(year) + year_range = Date.new(year, 1, 1)..Date.new(year, 12, 31) + non_working_times.for_year(year).flat_map do |t| ([t.start_date, year_range.begin].max..[t.end_date, year_range.end].min).to_a end.to_set - - (system_dates | user_dates).select { |d| working_wdays.include?(d.wday) } end protected diff --git a/modules/costs/app/components/my/time_tracking/calendar_component.rb b/modules/costs/app/components/my/time_tracking/calendar_component.rb index 8fe15677fec..8cd780699bd 100644 --- a/modules/costs/app/components/my/time_tracking/calendar_component.rb +++ b/modules/costs/app/components/my/time_tracking/calendar_component.rb @@ -40,7 +40,7 @@ module My private - def wrapper_data + def wrapper_data # rubocop:disable Metrics/AbcSize { "controller" => "my--time-tracking", "my--time-tracking-mode-value" => mode, From 7a7f9261c6f59025b280b57ac490668ecf8f3a95 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 7 Mar 2026 12:50:01 +0300 Subject: [PATCH 067/435] Fix state-machine bugs in identifier settings form - Swap wrapper_data_attrs condition: poll-for-changes must only be active in :change_in_progress state, not :edit/:completed - Replace update_to_alphanumeric? with autofix_requested? keyed on confirm_dangerous_action param (DangerDialog checkbox signal) - Use ActiveRecord::Type::Boolean cast for truthy check - Fix spec: radio group label renders as , not

; add visible: :all for hidden element assertion --- ...dentifier_settings_form_component.html.erb | 90 +++++++++---------- .../identifier_settings_form_component.rb | 16 ++-- .../work_packages_identifier_controller.rb | 8 +- ...entifier_autofix_section_component_spec.rb | 1 - .../settings/work_packages_identifier_spec.rb | 7 +- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index a825a57399b..4f9e3e87487 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -30,66 +30,59 @@ %> <%= component_wrapper(**wrapper_data_attrs) do %> - <%= tag.div(**stimulus_div_data_attrs) do %> + <%= + settings_primer_form_with( + scope: :settings, action: :update, method: :patch, + html: change_in_progress? ? {} : { id: form_id } + ) do |f| + render_inline_settings_form(f) do |form| + form.radio_button_group( + name: :work_packages_identifier, + label: I18n.t("settings.work_packages.work_package_identifier"), + required: true, + **radio_button_options + ) - <%= - settings_primer_form_with( - scope: :settings, action: :update, method: :patch, - html: change_in_progress? ? {} : { id: "wp-identifier-settings-form" } - ) do |f| - render_inline_settings_form(f) do |form| - form.radio_button_group( - name: :work_packages_identifier, - label: I18n.t("settings.work_packages.work_package_identifier"), - required: true, - **radio_button_options - ) - - form.html_content do - if change_in_progress? - render(Primer::Beta::Text.new(my: 3)) do - render(Primer::Beta::Spinner.new(size: :small, mr: 2)).to_s + - I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message") - end - elsif completed? - render(Primer::Alpha::Banner.new(scheme: :success, dismiss_scheme: :remove, mb: 3)) do - I18n.t("admin.settings.work_packages_identifier.success_banner") - end + form.html_content do + if change_in_progress? + render(Primer::Beta::Text.new(my: 3)) do + render(Primer::Beta::Spinner.new(size: :small, mr: 2)).to_s + + I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message") + end + elsif completed? + render(Primer::Alpha::Banner.new(scheme: :success, dismiss_scheme: :remove, mb: 3)) do + I18n.t("admin.settings.work_packages_identifier.success_banner") end end end end - %> + end + %> - <% unless change_in_progress? %> - <%= tag.div( - hidden: !show_autofix_section?, - data: { admin__work_packages_identifier_target: "autofixSection" } - ) do %> - <%= - render( + <% unless change_in_progress? %> + <%= tag.div( + hidden: !show_autofix_section?, + data: { admin__work_packages_identifier_target: "autofixSection" } + ) do %> + <%= render( WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent.new( projects_data:, total_count: ) - ) - %> - <% end %> - -
- <%= - render( + ) %> + <% end %> +
+ <%= render( Primer::Beta::Button.new( scheme: :primary, type: :submit, - form: "wp-identifier-settings-form", + form: form_id, hidden: show_autofix_section?, data: { admin__work_packages_identifier_target: "saveButton" } ) - ) { t("button_save") } - %> - <%= - render( + ) { t("button_save") } %> + + <%= render( Primer::Beta::Button.new( scheme: :primary, type: :button, @@ -99,11 +92,8 @@ action: "click->admin--work-packages-identifier#openConfirmDialog" } ) - ) { t("admin.settings.work_packages_identifier.button_autofix") } - %> -
- <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> - <% end %> - + ) { t("admin.settings.work_packages_identifier.button_autofix") } %> +
+ <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> <% end %> <% end %> diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index 90d1f0525f7..ca0fa622a7b 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -56,6 +56,8 @@ module WorkPackages private + def form_id = "wp-identifier-settings-form" + def show_autofix_section? state == :edit && Setting::WorkPackageIdentifier.alphanumeric? && has_problematic_projects? end @@ -64,20 +66,24 @@ module WorkPackages def completed? = state == :completed def wrapper_data_attrs - return {} unless change_in_progress? + if change_in_progress? + poll_for_changes_controller_attrs + else + work_package_identifier_controller_attrs + end + end + def poll_for_changes_controller_attrs { data: { controller: "poll-for-changes", - poll_for_changes_url_value: helpers.status_admin_settings_work_packages_identifier_path, + poll_for_changes_url_value: url_helpers.status_admin_settings_work_packages_identifier_path, poll_for_changes_interval_value: 5000 } } end - def stimulus_div_data_attrs - return {} if change_in_progress? - + def work_package_identifier_controller_attrs { data: { controller: "admin--work-packages-identifier", diff --git a/app/controllers/admin/settings/work_packages_identifier_controller.rb b/app/controllers/admin/settings/work_packages_identifier_controller.rb index b66822ae0d2..3e6bc60dc1e 100644 --- a/app/controllers/admin/settings/work_packages_identifier_controller.rb +++ b/app/controllers/admin/settings/work_packages_identifier_controller.rb @@ -43,9 +43,9 @@ module Admin::Settings end def update - return unless params[:settings] + return render_400 unless params[:settings] - if ActiveRecord::Type::Boolean.new.cast(params[:confirm_dangerous_action]) + if autofix_requested? call = update_service.new(user: current_user).call(settings_params) call.on_success do WorkPackages::IdentifierAutofix::ApplyHandlesJob.perform_later @@ -73,5 +73,9 @@ module Admin::Settings def check_feature_flag render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? end + + def autofix_requested? + ActiveRecord::Type::Boolean.new.cast(params[:confirm_dangerous_action]) + end end end diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb index 36e1bc97607..da36555059c 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb @@ -159,5 +159,4 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent, expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.box_header.label_example_work_package_id")) end end - end diff --git a/spec/features/admin/settings/work_packages_identifier_spec.rb b/spec/features/admin/settings/work_packages_identifier_spec.rb index 577acf9edfa..2cdd70191c5 100644 --- a/spec/features/admin/settings/work_packages_identifier_spec.rb +++ b/spec/features/admin/settings/work_packages_identifier_spec.rb @@ -42,8 +42,8 @@ RSpec.describe "Work packages identifier admin settings", :js do def visit_settings visit settings_path - # Wait for the page heading to confirm the page has loaded - expect(page).to have_css("h2, h1", text: I18n.t("settings.work_packages.work_package_identifier"), + # Wait for the radio group legend to confirm the page has loaded + expect(page).to have_css("legend", text: I18n.t("settings.work_packages.work_package_identifier"), wait: 10) end @@ -67,7 +67,8 @@ RSpec.describe "Work packages identifier admin settings", :js do # The autofix section is hidden when numeric is selected expect(page).to have_css( - "[data-admin--work-packages-identifier-target=autofixSection][hidden]" + "[data-admin--work-packages-identifier-target=autofixSection][hidden]", + visible: :all ) click_button I18n.t("button_save") From 45b61771a710c85d95af9d67146d79d67d8233ba Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 7 Mar 2026 12:50:19 +0300 Subject: [PATCH 068/435] Document single-job assumption on ApplyHandlesJob The status polling UI assumes at most one active instance of this job at a time. Add a FIXME to enforce this with GoodJob concurrency control once the real migration body is implemented. --- .../work_packages/identifier_autofix/apply_handles_job.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/workers/work_packages/identifier_autofix/apply_handles_job.rb b/app/workers/work_packages/identifier_autofix/apply_handles_job.rb index 9ac0bdbcd43..ff2107b4a3e 100644 --- a/app/workers/work_packages/identifier_autofix/apply_handles_job.rb +++ b/app/workers/work_packages/identifier_autofix/apply_handles_job.rb @@ -29,6 +29,10 @@ #++ class WorkPackages::IdentifierAutofix::ApplyHandlesJob < ApplicationJob + # FIXME: The admin UI's job_in_progress? query and :change_in_progress state + # assume at most one active instance of this job at any given time. + # Enforce this with good_job_control_concurrency_with(perform_limit: 1) + # when the real migration body is implemented. def perform # FIXME: replace with actual project handle migration sleep 5 From 1a59617fb579dfa95c0ffd8615ad71406a655c2c Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 7 Mar 2026 12:52:10 +0300 Subject: [PATCH 069/435] Suppress empty ?reference= param in poll-for-changes controller Only append the reference query param when a non-empty reference value is available. Callers that use the reference mechanism (e.g. meetings, which always supply a changed_hash) are unaffected. --- .../src/stimulus/controllers/poll-for-changes.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/stimulus/controllers/poll-for-changes.controller.ts b/frontend/src/stimulus/controllers/poll-for-changes.controller.ts index 6b439f6335b..20e18ea70ca 100644 --- a/frontend/src/stimulus/controllers/poll-for-changes.controller.ts +++ b/frontend/src/stimulus/controllers/poll-for-changes.controller.ts @@ -76,7 +76,8 @@ export default class PollForChangesController extends ApplicationController { triggerTurboStream() { const url = new URL(this.urlValue, window.location.origin); - url.searchParams.set('reference', this.buildReference()); + const ref = this.buildReference(); + if (ref) url.searchParams.set('reference', ref); void fetch(url.toString()) .then(async (r) => { From ebc0e4dd794fd4c44d2df177fdb815330e700be3 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 7 Mar 2026 13:24:02 +0300 Subject: [PATCH 070/435] Fix handle_from_name for single-word project names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-word names previously produced a 1-char handle ("Banana" → "B"). Add a SINGLE_WORD_LENGTH = 3 constant and branch handle_from_name so single-word names return the first 3 transliterated, uppercased chars ("Banana" → "BAN", "Kiwi" → "KIW"). Multi-word names continue to use the initials/acronym path unchanged. --- .../project_handle_suggestion_generator.rb | 15 +++++++++++++-- config/constants/settings/definition.rb | 2 +- .../project_handle_suggestion_generator_spec.rb | 6 ++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb index a509cb2fa1f..6f60553379f 100644 --- a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb @@ -46,6 +46,7 @@ module WorkPackages # class ProjectHandleSuggestionGenerator HANDLE_MAX_LENGTH = 5 + SINGLE_WORD_LENGTH = 3 FALLBACK_HANDLE = "PROJ" SUFFIX_LIMIT = 10_000 @@ -84,12 +85,22 @@ module WorkPackages words = name.to_s.scan(/[[:alpha:][:digit:]]+/) return FALLBACK_HANDLE if words.empty? - # Transliterate each word's first character to ASCII (é→e, ñ→n) then upcase. + words.size == 1 ? handle_from_single_word(words.first) : handle_from_words(words) + end + + def handle_from_single_word(word) + # e.g. "Banana" → "BAN", "Kiwi" → "KIW", "日本語" → FALLBACK_HANDLE + t = I18n.with_locale(:en) { I18n.transliterate(word) } + chars = t.scan(/[A-Za-z0-9]/).first(SINGLE_WORD_LENGTH).map(&:upcase).join + chars.empty? ? FALLBACK_HANDLE : chars + end + + def handle_from_words(words) + # Multi-word names: take initials (first letter of each word), truncated. acronym = words.filter_map do |word| ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] ch if ch&.match?(/\A[A-Z0-9]\z/) end.join - return FALLBACK_HANDLE if acronym.empty? acronym.slice(0, HANDLE_MAX_LENGTH) diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 7f4f7f900ff..05c5d7de799 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -1597,7 +1597,7 @@ module Settings .scan(/(?:[a-zA-Z0-9]|__)+/) .map do |seg| unescape_underscores(seg.downcase) - end + end end # takes the path provided and transforms it into a deeply nested hash diff --git a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb index afead32f00e..6ec0b70c3bf 100644 --- a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb @@ -83,6 +83,12 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator describe "handle generation from project name" do { + # Single-word names: first SINGLE_WORD_LENGTH (3) transliterated chars + "Banana" => "BAN", + "Kiwi" => "KIW", + "Strawberry" => "STR", + "Cécile" => "CEC", # single word with accented letter + # Multi-word names: initials, truncated to HANDLE_MAX_LENGTH (5) "Flight Planning Algorithm" => "FPA", "Fly & Sky" => "FS", "Social media marketing" => "SMM", From 36514d957101b669fdaf37017aeab6c05c73aee6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 7 Mar 2026 14:17:10 +0300 Subject: [PATCH 071/435] Address code review concerns for identifier settings UI - Move DISPLAY_COUNT constant from IdentifierAutofixSectionComponent to PreviewQuery, eliminating a service-layer dependency on a view component. The component now forwards to PreviewQuery::DISPLAY_COUNT. - Guard PreviewQuery.new.call to only run in the :edit state. Previously it executed on every render, hitting the DB twice per Hotwire status-poll during the :change_in_progress phase. - Replace nil guard in error_label with I18n.t default: "" to cover any unrecognised error reason, not just nil. - Add component spec for IdentifierSettingsFormComponent covering all three states (:change_in_progress, :completed, :edit) including the autofix-section visibility branch. - Update preview_query_spec to reference PreviewQuery::DISPLAY_COUNT directly instead of the UI component constant. --- .../identifier_autofix_section_component.rb | 5 +- .../identifier_settings_form_component.rb | 13 +- .../identifier_autofix/preview_query.rb | 3 +- ...identifier_settings_form_component_spec.rb | 160 ++++++++++++++++++ .../identifier_autofix/preview_query_spec.rb | 2 +- 5 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb index b2484c35dd0..b1c16900d93 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb @@ -34,7 +34,7 @@ module WorkPackages class IdentifierAutofixSectionComponent < ApplicationComponent include OpPrimer::ComponentHelpers - DISPLAY_COUNT = 5 + DISPLAY_COUNT = WorkPackages::IdentifierAutofix::PreviewQuery::DISPLAY_COUNT def initialize(projects_data:, total_count: projects_data.size) super() @@ -48,7 +48,8 @@ module WorkPackages attr_reader :total_count, :displayed, :remaining_count def error_label(error_reason) - I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_#{error_reason}") + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_#{error_reason}", + default: "") end # Produces a realistic-looking example work package ID for the preview table. diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index ca0fa622a7b..3e9c28d41dd 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -44,10 +44,15 @@ module WorkPackages raise ArgumentError, "Unknown state: #{state}" unless STATES.include?(state) super() - @state = state - result = WorkPackages::IdentifierAutofix::PreviewQuery.new.call - @projects_data = result.projects_data - @total_count = result.total_count + @state = state + if state == :edit + result = WorkPackages::IdentifierAutofix::PreviewQuery.new.call + @projects_data = result.projects_data + @total_count = result.total_count + else + @projects_data = [] + @total_count = 0 + end end def has_problematic_projects? diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb index 5cbf48d42e5..63a8fd1b806 100644 --- a/app/services/work_packages/identifier_autofix/preview_query.rb +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -32,12 +32,13 @@ module WorkPackages module IdentifierAutofix class PreviewQuery Result = Data.define(:projects_data, :total_count) + DISPLAY_COUNT = 5 def call total = problematic_scope.count preview = problematic_scope .select(:id, :name, :identifier) - .limit(WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT) + .limit(DISPLAY_COUNT) .to_a suggestions = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.call( diff --git a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb new file mode 100644 index 00000000000..f7f214379e9 --- /dev/null +++ b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, type: :component do + subject(:component) { described_class.new(state:) } + + let(:empty_result) do + WorkPackages::IdentifierAutofix::PreviewQuery::Result.new(projects_data: [], total_count: 0) + end + + def render_component(component) + with_controller_class(Admin::Settings::WorkPackagesIdentifierController) do + with_request_url("/admin/settings/work_packages_identifier") do + render_inline(component) + end + end + end + + before do + preview_stub = instance_double(WorkPackages::IdentifierAutofix::PreviewQuery, call: empty_result) + allow(WorkPackages::IdentifierAutofix::PreviewQuery).to receive(:new).and_return(preview_stub) + end + + context "when state is :change_in_progress" do + let(:state) { :change_in_progress } + + it "renders the in-progress spinner message" do + render_component(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message")) + end + + it "does not render the success banner" do + render_component(component) + expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.success_banner")) + end + + it "renders the radio buttons as disabled" do + render_component(component) + expect(page).to have_css("input[type=radio][disabled]") + end + + it "does not render the save or autofix buttons" do + render_component(component) + expect(page).to have_no_button(I18n.t("button_save")) + expect(page).to have_no_button(I18n.t("admin.settings.work_packages_identifier.button_autofix")) + end + + it "does not call PreviewQuery" do + render_component(component) + expect(WorkPackages::IdentifierAutofix::PreviewQuery).not_to have_received(:new) + end + end + + context "when state is :completed" do + let(:state) { :completed } + + it "renders the success banner" do + render_component(component) + expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.success_banner")) + end + + it "does not render the in-progress spinner message" do + render_component(component) + expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message")) + end + + it "renders the radio buttons as enabled" do + render_component(component) + expect(page).to have_no_css("input[type=radio][disabled]") + end + + it "does not call PreviewQuery" do + render_component(component) + expect(WorkPackages::IdentifierAutofix::PreviewQuery).not_to have_received(:new) + end + end + + context "when state is :edit" do + let(:state) { :edit } + + it "calls PreviewQuery" do + render_component(component) + expect(WorkPackages::IdentifierAutofix::PreviewQuery).to have_received(:new).once + end + + it "renders the save button" do + render_component(component) + expect(page).to have_button(I18n.t("button_save")) + end + + it "does not render in-progress or success content" do + render_component(component) + expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message")) + expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.success_banner")) + end + + context "with problematic projects and alphanumeric setting", + with_settings: { work_packages_identifier: "alphanumeric" } do + let(:project) { instance_double(Project, name: "Bad Project", id: 1, to_param: "bad-proj") } + let(:problematic_result) do + WorkPackages::IdentifierAutofix::PreviewQuery::Result.new( + projects_data: [ + { project:, current_identifier: "bad-proj", suggested_handle: "BP", error_reason: :special_characters } + ], + total_count: 1 + ) + end + + before do + stub = instance_double(WorkPackages::IdentifierAutofix::PreviewQuery, call: problematic_result) + allow(WorkPackages::IdentifierAutofix::PreviewQuery).to receive(:new).and_return(stub) + end + + it "hides the plain save button" do + render_component(component) + expect(page).to have_no_button(I18n.t("button_save")) + end + + it "renders the autofix button" do + render_component(component) + expect(page).to have_button(I18n.t("admin.settings.work_packages_identifier.button_autofix")) + end + end + end + + context "when an unknown state is given" do + it "raises ArgumentError" do + expect { described_class.new(state: :bogus) }.to raise_error(ArgumentError, /Unknown state/) + end + end +end diff --git a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb index 66fcfa7ab35..2c9c9c7780f 100644 --- a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb +++ b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb @@ -33,7 +33,7 @@ require "rails_helper" RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do subject(:result) { described_class.new.call } - let(:display_count) { WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent::DISPLAY_COUNT } + let(:display_count) { described_class::DISPLAY_COUNT } def create_problematic_project(name:, identifier:) create(:project, name:, identifier:) From 5a73898c9b764b05795546712d3c9176dbdc1e73 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 7 Mar 2026 15:45:28 +0300 Subject: [PATCH 072/435] Use accessible selectors and raw strings in identifier settings specs Replace CSS role selectors with capybara_accessible_selectors helpers and substitute raw English strings for I18n.t() calls where the value serves as a contract on the exact locale text. --- .../identifier_settings_form_component_spec.rb | 4 ++-- .../admin/settings/work_packages_identifier_spec.rb | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb index f7f214379e9..2e068d33d75 100644 --- a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb @@ -65,7 +65,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the radio buttons as disabled" do render_component(component) - expect(page).to have_css("input[type=radio][disabled]") + expect(page).to have_field("Instance-wide numerical sequence (default)", disabled: true) end it "does not render the save or autofix buttons" do @@ -95,7 +95,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the radio buttons as enabled" do render_component(component) - expect(page).to have_no_css("input[type=radio][disabled]") + expect(page).to have_field("Instance-wide numerical sequence (default)", disabled: false) end it "does not call PreviewQuery" do diff --git a/spec/features/admin/settings/work_packages_identifier_spec.rb b/spec/features/admin/settings/work_packages_identifier_spec.rb index 2cdd70191c5..88d718276d6 100644 --- a/spec/features/admin/settings/work_packages_identifier_spec.rb +++ b/spec/features/admin/settings/work_packages_identifier_spec.rb @@ -54,7 +54,7 @@ RSpec.describe "Work packages identifier admin settings", :js do click_button I18n.t("button_save") expect(page).to have_current_path(settings_path) - expect(page).to have_no_css("[role=alertdialog]") + expect(page).to have_no_dialog end end @@ -73,7 +73,7 @@ RSpec.describe "Work packages identifier admin settings", :js do click_button I18n.t("button_save") expect(page).to have_current_path(settings_path) - expect(page).to have_no_css("[role=alertdialog]") + expect(page).to have_no_dialog end end @@ -93,13 +93,13 @@ RSpec.describe "Work packages identifier admin settings", :js do it "opens the confirmation dialog when 'Autofix and save' is clicked" do click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") - expect(page).to have_css("[role=alertdialog]", visible: :visible) + expect(page).to have_dialog "Change work package identifiers" end it "shows the dialog heading and checkbox" do click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") - within("[role=alertdialog]") do + within_dialog "Change work package identifiers" do expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.dialog.heading")) expect(page).to have_field( I18n.t("admin.settings.work_packages_identifier.dialog.checkbox_label"), @@ -111,7 +111,7 @@ RSpec.describe "Work packages identifier admin settings", :js do it "enables the confirm button only after checking the checkbox" do click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") - within("[role=alertdialog]") do + within "[role=alertdialog]" do confirm_text = I18n.t("admin.settings.work_packages_identifier.dialog.confirm_button") expect(page).to have_button(confirm_text, disabled: true) From 88205c911c5d3f3df826a4945f0f1fb0c31f2bd3 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 9 Mar 2026 08:45:29 +0300 Subject: [PATCH 073/435] Async-fetch confirm dialog via respond_with_dialog Replace the imperative Stimulus openConfirmDialog action (which pre-rendered the dialog in the page and called showModal() via JS) with the idiomatic Hotwire approach: a GET endpoint that streams the dialog on demand. - Add confirm_dialog route and controller action using respond_with_dialog - Include OpTurbo::Streamable in ChangeIdentifiersDialogComponent - Convert autofix button to a link with data-turbo-stream pointing to the new route; remove static dialog render from the form template - Remove openConfirmDialog method from the Stimulus controller - Update specs to use click_on and raw English strings instead of I18n.t() Relates to #72461 --- .../change_identifiers_dialog_component.rb | 1 + ...dentifier_settings_form_component.html.erb | 8 ++--- .../work_packages_identifier_controller.rb | 4 +++ config/routes.rb | 1 + .../work-packages-identifier.controller.ts | 4 --- .../settings/work_packages_identifier_spec.rb | 34 ++++++++----------- 6 files changed, 24 insertions(+), 28 deletions(-) diff --git a/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb b/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb index 1b5f265bdcf..d9d7c380941 100644 --- a/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb +++ b/app/components/work_packages/admin/settings/change_identifiers_dialog_component.rb @@ -33,6 +33,7 @@ module WorkPackages module Settings class ChangeIdentifiersDialogComponent < ApplicationComponent include OpPrimer::ComponentHelpers + include OpTurbo::Streamable end end end diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb index 4f9e3e87487..43c130fc707 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.html.erb @@ -84,16 +84,16 @@ <%= render( Primer::Beta::Button.new( + tag: :a, + href: confirm_dialog_admin_settings_work_packages_identifier_path, scheme: :primary, - type: :button, hidden: !show_autofix_section?, data: { - admin__work_packages_identifier_target: "autofixButton", - action: "click->admin--work-packages-identifier#openConfirmDialog" + turbo_stream: true, + admin__work_packages_identifier_target: "autofixButton" } ) ) { t("admin.settings.work_packages_identifier.button_autofix") } %> - <%= render(WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new) %> <% end %> <% end %> diff --git a/app/controllers/admin/settings/work_packages_identifier_controller.rb b/app/controllers/admin/settings/work_packages_identifier_controller.rb index 3e6bc60dc1e..1e4ecbb008f 100644 --- a/app/controllers/admin/settings/work_packages_identifier_controller.rb +++ b/app/controllers/admin/settings/work_packages_identifier_controller.rb @@ -57,6 +57,10 @@ module Admin::Settings end end + def confirm_dialog + respond_with_dialog WorkPackages::Admin::Settings::ChangeIdentifiersDialogComponent.new + end + def status if WorkPackages::IdentifierAutofix.job_in_progress? head :no_content diff --git a/config/routes.rb b/config/routes.rb index 74c0e7d76f2..71996e5fc14 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -662,6 +662,7 @@ Rails.application.routes.draw do resource :work_packages_general, controller: "/admin/settings/work_packages_general", only: %i[show update] resource :work_packages_identifier, controller: "/admin/settings/work_packages_identifier", only: %i[show update] do get :status, on: :member + get :confirm_dialog, on: :member, defaults: { format: :turbo_stream } end resources :work_package_priorities, except: [:show] do member do diff --git a/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts index d65d2fcd44e..e40bff2a598 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-identifier.controller.ts @@ -51,10 +51,6 @@ export default class WorkPackagesIdentifierController extends Controller { this.updateVisibility(); } - openConfirmDialog() { - (document.getElementById('change-identifiers-dialog') as HTMLDialogElement)?.showModal(); - } - private updateVisibility() { const showAutofix = this.isAlphanumericSelected() && this.hasProblematicProjectsValue; diff --git a/spec/features/admin/settings/work_packages_identifier_spec.rb b/spec/features/admin/settings/work_packages_identifier_spec.rb index 88d718276d6..792455a4993 100644 --- a/spec/features/admin/settings/work_packages_identifier_spec.rb +++ b/spec/features/admin/settings/work_packages_identifier_spec.rb @@ -43,15 +43,14 @@ RSpec.describe "Work packages identifier admin settings", :js do def visit_settings visit settings_path # Wait for the radio group legend to confirm the page has loaded - expect(page).to have_css("legend", text: I18n.t("settings.work_packages.work_package_identifier"), - wait: 10) + expect(page).to have_css("legend", text: "Work package identifier", wait: 10) end context "when no projects have problematic identifiers" do it "saves the setting without showing a dialog" do visit_settings - click_button I18n.t("button_save") + click_button "Save" expect(page).to have_current_path(settings_path) expect(page).to have_no_dialog @@ -70,7 +69,7 @@ RSpec.describe "Work packages identifier admin settings", :js do "[data-admin--work-packages-identifier-target=autofixSection][hidden]", visible: :all ) - click_button I18n.t("button_save") + click_button "Save" expect(page).to have_current_path(settings_path) expect(page).to have_no_dialog @@ -80,7 +79,7 @@ RSpec.describe "Work packages identifier admin settings", :js do context "when switching to alphanumeric" do before do visit_settings - choose I18n.t("setting_work_packages_identifier_alphanumeric") + choose "Project-based alphanumerical identifiers" end it "shows the autofix section after selecting alphanumeric" do @@ -91,43 +90,38 @@ RSpec.describe "Work packages identifier admin settings", :js do end it "opens the confirmation dialog when 'Autofix and save' is clicked" do - click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") + click_on "Autofix and save" expect(page).to have_dialog "Change work package identifiers" end it "shows the dialog heading and checkbox" do - click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") + click_on "Autofix and save" within_dialog "Change work package identifiers" do - expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.dialog.heading")) + expect(page).to have_text("Enable project-based work package IDs?") expect(page).to have_field( - I18n.t("admin.settings.work_packages_identifier.dialog.checkbox_label"), + "I understand that this will permanently change all work package IDs", type: :checkbox ) end end it "enables the confirm button only after checking the checkbox" do - click_button I18n.t("admin.settings.work_packages_identifier.button_autofix") + click_on "Autofix and save" within "[role=alertdialog]" do - confirm_text = I18n.t("admin.settings.work_packages_identifier.dialog.confirm_button") + expect(page).to have_button("Change identifiers", disabled: true) - expect(page).to have_button(confirm_text, disabled: true) + check "I understand that this will permanently change all work package IDs" - check I18n.t("admin.settings.work_packages_identifier.dialog.checkbox_label") - - expect(page).to have_button(confirm_text, disabled: false) + expect(page).to have_button("Change identifiers", disabled: false) end end it "hides the plain Save button when autofix section is visible" do - expect(page).to have_no_button(I18n.t("button_save")) - expect(page).to have_button( - I18n.t("admin.settings.work_packages_identifier.button_autofix"), - disabled: false - ) + expect(page).to have_no_button("Save") + expect(page).to have_link("Autofix and save") end end end From 1c83d3410b5113199f6b733e3b94012539cbeae6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 9 Mar 2026 08:51:12 +0300 Subject: [PATCH 074/435] Assert both radio buttons disabled/enabled in identifier settings spec Previously only the first radio button was checked by label. Now both "Instance-wide numerical sequence" and "Project-based alphanumerical identifiers" are asserted for completeness. --- .../admin/settings/identifier_settings_form_component_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb index 2e068d33d75..f6b91f84042 100644 --- a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb @@ -66,6 +66,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the radio buttons as disabled" do render_component(component) expect(page).to have_field("Instance-wide numerical sequence (default)", disabled: true) + expect(page).to have_field("Project-based alphanumerical identifiers", disabled: true) end it "does not render the save or autofix buttons" do @@ -96,6 +97,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the radio buttons as enabled" do render_component(component) expect(page).to have_field("Instance-wide numerical sequence (default)", disabled: false) + expect(page).to have_field("Project-based alphanumerical identifiers", disabled: false) end it "does not call PreviewQuery" do From 0f38c6d953cb7499ac6552befb0f38c96c061f65 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 9 Mar 2026 08:59:48 +0300 Subject: [PATCH 075/435] Fix component spec: use have_link for autofix button and raw strings The autofix button is now a link ( tag), so update the component spec to use have_link/have_no_link instead of have_button. Also replace all I18n.t() calls with raw English strings throughout the spec. --- ...identifier_settings_form_component_spec.rb | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb index f6b91f84042..1ba8bd21c3e 100644 --- a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb @@ -55,12 +55,12 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the in-progress spinner message" do render_component(component) - expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message")) + expect(page).to have_text("Project identifiers are currently being updated to project-based alphanumerical identifiers.") end it "does not render the success banner" do render_component(component) - expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.success_banner")) + expect(page).to have_no_text("Successfully updated work package identifier format.") end it "renders the radio buttons as disabled" do @@ -71,8 +71,8 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "does not render the save or autofix buttons" do render_component(component) - expect(page).to have_no_button(I18n.t("button_save")) - expect(page).to have_no_button(I18n.t("admin.settings.work_packages_identifier.button_autofix")) + expect(page).to have_no_button("Save") + expect(page).to have_no_link("Autofix and save") end it "does not call PreviewQuery" do @@ -86,12 +86,12 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the success banner" do render_component(component) - expect(page).to have_text(I18n.t("admin.settings.work_packages_identifier.success_banner")) + expect(page).to have_text("Successfully updated work package identifier format.") end it "does not render the in-progress spinner message" do render_component(component) - expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message")) + expect(page).to have_no_text("Project identifiers are currently being updated to project-based alphanumerical identifiers.") end it "renders the radio buttons as enabled" do @@ -116,13 +116,13 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "renders the save button" do render_component(component) - expect(page).to have_button(I18n.t("button_save")) + expect(page).to have_button("Save") end it "does not render in-progress or success content" do render_component(component) - expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.in_progress.banner_message")) - expect(page).to have_no_text(I18n.t("admin.settings.work_packages_identifier.success_banner")) + expect(page).to have_no_text("Project identifiers are currently being updated to project-based alphanumerical identifiers.") + expect(page).to have_no_text("Successfully updated work package identifier format.") end context "with problematic projects and alphanumeric setting", @@ -144,12 +144,12 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t it "hides the plain save button" do render_component(component) - expect(page).to have_no_button(I18n.t("button_save")) + expect(page).to have_no_button("Save") end it "renders the autofix button" do render_component(component) - expect(page).to have_button(I18n.t("admin.settings.work_packages_identifier.button_autofix")) + expect(page).to have_link("Autofix and save") end end end From 45d9c5f12ce836fc5f0dfcd73a06f70a39aaa349 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 11 Mar 2026 09:14:51 +0100 Subject: [PATCH 076/435] Support custom comments for custom fields --- .../common/inplace_edit_field_component.rb | 2 +- .../base_field_component.rb | 14 +++ .../boolean_input_component.rb | 14 ++- .../date_input_component.rb | 18 ++- .../rich_text_area_component.rb | 2 + .../select_list_component.rb | 4 +- .../text_input_component.rb | 16 ++- .../inplace_edit_fields_controller.rb | 15 ++- .../patterns/06-inplace-edit-fields.md.erb | 112 +++++++++++++++--- 9 files changed, 169 insertions(+), 28 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index 013163b9530..7d099b2b9c8 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -125,7 +125,7 @@ module OpenProject end def open_in_dialog? - @open_in_dialog + @open_in_dialog || (custom_field? && custom_field&.has_comment?) end def dialog_edit_url diff --git a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb index efd4b5012ec..d6cde28f258 100644 --- a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb @@ -47,6 +47,20 @@ module OpenProject @system_arguments = system_arguments end + def comment_field_if_enabled(form) + return unless show_comment_field? + + form.text_area(name: "#{model.class.model_name.param_key}[custom_comments][#{custom_field.id}]", + scope_name_to_model: false, + label: I18n.t("attributes.comment"), + value: model.custom_comment_for(custom_field)&.text, + rows: 5) + end + + def show_comment_field? + custom_field? && custom_field&.has_comment? + end + def custom_field? attribute.to_s.start_with?("custom_field_") end diff --git a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb index b20c2bfebc1..99b2530a8a2 100644 --- a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb @@ -34,9 +34,10 @@ module OpenProject class BooleanInputComponent < BaseFieldComponent def call form.check_box name: attribute, - data: { controller: "inplace-edit", - action: "click->inplace-edit#submitForm" }, + **additional_arguments, **@system_arguments + + comment_field_if_enabled(form) end private @@ -49,6 +50,15 @@ module OpenProject system_arguments_json: @system_arguments.to_json ) end + + def additional_arguments + if show_action_buttons + { + data: { controller: "inplace-edit", + action: "click->inplace-edit#submitForm" } + } + end + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb index 16e5e3d679d..eecb7480dc5 100644 --- a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb @@ -39,10 +39,22 @@ module OpenProject def call form.text_field name: attribute, - data: { controller: "inplace-edit", - inplace_edit_url_value: reset_url, - action: "keydown.esc->inplace-edit#request change->inplace-edit#submitForm" }, + **additional_arguments, **@system_arguments + + comment_field_if_enabled(form) + end + + private + + def additional_arguments + if show_action_buttons + { + data: { controller: "inplace-edit", + inplace_edit_url_value: reset_url, + action: "keydown.esc->inplace-edit#request change->inplace-edit#submitForm" } + } + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index ad9450584b5..f694ef03442 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -56,6 +56,8 @@ module OpenProject }, **@system_arguments) + comment_field_if_enabled(form) + if show_action_buttons form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| button_group.submit(name: :reset, diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index 516378c10cf..3aa4524d0e2 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -60,7 +60,9 @@ module OpenProject render_autocompleter end - if @show_action_buttons + comment_field_if_enabled(form) + + if show_action_buttons form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| button_group.submit(name: :reset, type: :submit, diff --git a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb index ebb6b9be8d8..46b1f87e0ab 100644 --- a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb @@ -35,10 +35,10 @@ module OpenProject def call form.text_field name: attribute, autofocus: true, - data: { controller: "inplace-edit", - inplace_edit_url_value: reset_url, - action: "keydown.esc->inplace-edit#request" }, + **additional_arguments, **@system_arguments + + comment_field_if_enabled(form) end private @@ -51,6 +51,16 @@ module OpenProject system_arguments_json: @system_arguments.to_json ) end + + def additional_arguments + if show_action_buttons + { + data: { controller: "inplace-edit", + inplace_edit_url_value: reset_url, + action: "keydown.esc->inplace-edit#request" } + } + end + end end end end diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index 6d446145b35..c9d965e2e62 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -118,9 +118,9 @@ class InplaceEditFieldsController < ApplicationController def permitted_params if custom_field_via_fields_for? - transform_custom_field_values_params + transform_custom_field_values_params.merge(custom_comments_params) else - params.expect(@model.model_name.param_key => [@attribute]) + params.expect(@model.model_name.param_key => [@attribute]).merge(custom_comments_params) end end @@ -129,6 +129,17 @@ class InplaceEditFieldsController < ApplicationController params[@model.model_name.param_key]&.key?(:custom_field_values) end + def custom_comments_params + return {} unless @attribute.to_s.start_with?("custom_field_") + + custom_field_id = @attribute.to_s.delete_prefix("custom_field_") + raw_comment = params.dig(@model.model_name.param_key, :custom_comments, custom_field_id) + + return {} if raw_comment.nil? + + { custom_comments: { custom_field_id => raw_comment } } + end + def transform_custom_field_values_params model_key = @model.model_name.param_key custom_field_id = @attribute.to_s.delete_prefix("custom_field_") diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index de9663957a6..ceefb0b6262 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -9,9 +9,13 @@ The InplaceEdit system consists of: - **A generic wrapper component** (`InplaceEditFieldComponent`) +- **A base class for edit field components** + (`BaseFieldComponent`) - **Edit field components** (e.g. `TextInputComponent`, `RichTextAreaComponent`) - **Optional display field components** +- **A dialog component** + (`InplaceEditFieldDialogComponent`) - **A central registry** - **A generic controller** - **TurboStreams + Stimulus** for lazy loading @@ -31,9 +35,24 @@ OpenProject::Common::InplaceEditFieldComponent.new( The `InplaceEditFieldComponent` is the **single entry point used in views**. It is initialized with a model and an attribute and decides which concrete field component to render. It also decides whether the component is currently in display mode or edit mode. -Only model and attribute are required. All additional keyword arguments are treated as system arguments and forwarded unchanged through Turbo roundtrips. Editability is determined via a contract and exposed through the `writable?` check. +Only `model` and `attribute` are required. All additional keyword arguments are treated as system arguments and forwarded unchanged through Turbo roundtrips. Editability is determined via a contract and exposed through the `writable?` check. -The component resolves the edit field via the `FieldRegistry` and optionally a display field via the edit field’s `display_class`. +The component resolves the edit field via the `FieldRegistry` and optionally a display field via the edit field's `display_class`. + +**Parameters:** + +| Parameter | Default | Description | +|---|---|---| +| `model` | — | The ActiveRecord model instance | +| `attribute` | — | The attribute name as a symbol | +| `enforce_edit_mode` | `false` | Always render in edit mode, skip display component | +| `open_in_dialog` | `false` | Force edit form to open in a dialog | +| `show_action_buttons` | `true` | Show save/cancel buttons inside the field component | +| `truncated` | `false` | Pass truncation hint to the display component | + +**Automatic dialog mode:** + +The component switches to dialog mode automatically (regardless of the `open_in_dialog` parameter) when the attribute is a custom field that has comments enabled (`custom_field.has_comment?`). This ensures that the comment field is always presented alongside the value field in a dialog. **Simplified HTML of the `InplaceEditFieldComponent`:** ```html @@ -59,6 +78,7 @@ end %> - selecting the correct edit field - if needed: rendering the appropriate display field - checking whether the attribute is writable +- deciding between inline and dialog edit mode #### FieldRegistry @@ -74,6 +94,37 @@ OpenProject::InplaceEdit::FieldRegistry.register( ) ``` +#### BaseFieldComponent + +`BaseFieldComponent` is the **base class for all edit field components**. It provides shared functionality that concrete field components can use, and should be inherited from when building new field components. + +It handles the optional rendering of a comment field for custom fields with comments enabled. + +```ruby +module OpenProject + module Common + module InplaceEditFields + class MyCustomComponent < BaseFieldComponent + def self.display_class + DisplayFields::DisplayFieldComponent + end + + def call + form.text_field(name: attribute, **@system_arguments) + comment_field_if_enabled(form) + end + end + end + end +end +``` + +**`comment_field_if_enabled(f)`** + +Call this helper from any field component's `call` method to conditionally render a comment textarea. It renders only when the attribute is a custom field with comments enabled (`custom_field.has_comment?`). + +The generated form field name follows the pattern `model[custom_comments][custom_field_id]` (e.g. `project[custom_comments][18]`), so that multiple custom field comments can coexist in the same form submission. + #### EditFieldComponents `EditFieldComponents` are responsible for rendering the actual form field. They receive a form builder, the model, the attribute, and the forwarded system arguments. @@ -82,26 +133,21 @@ They may render only the field itself or also include submit and reset buttons. Edit field components define a `display_class`. This class is used to render the read-only display state. +All field components should inherit from `BaseFieldComponent` to get access to the comment field helper and other shared behaviour. + **Simplified example of an `EditFieldComponent`:** ```ruby module OpenProject module Common module InplaceEditFields - class RichTextAreaComponent < ViewComponent::Base + class RichTextAreaComponent < BaseFieldComponent def self.display_class DisplayFields::RichTextAreaComponent end - def initialize(form:, attribute:, model:, **system_arguments) - super() - @form = form - @attribute = attribute - @model = model - @system_arguments = system_arguments - end - def call form.rich_text_area(name: attribute, **@system_arguments) + comment_field_if_enabled(form) form.group(layout: :horizontal) do |button_group| button_group.submit(name: :reset, @@ -150,18 +196,48 @@ module OpenProject end ``` +#### InplaceEditFieldDialogComponent + +When a field is configured to open in dialog mode, the display component renders a trigger button instead of a direct click target. Clicking the button fetches and opens the `InplaceEditFieldDialogComponent` via a lazy-loaded Turbo request. + +The dialog wraps an `InplaceEditFieldComponent` in `enforce_edit_mode: true` and renders save/cancel buttons in its footer. The edit form inside the dialog is linked to the footer buttons via a shared `form_id`. + +This is used automatically for custom fields that have comments enabled, ensuring the comment textarea is presented together with the value in a dialog rather than inline. + +``` +Display component (with dialog trigger) + └─ click → GET /inplace_edit_fields/dialog?... + └─ InplaceEditFieldDialogComponent + ├─ Primer::Alpha::Dialog + │ ├─ body: InplaceEditFieldComponent (enforce_edit_mode: true) + │ │ └─ EditFieldComponent + │ │ ├─ value field + │ │ └─ comment textarea (if has_comment?) + │ └─ footer: Save / Cancel buttons +``` + ### Update behaviour #### InplaceEditFieldsController The `InplaceEditFieldsController` is a generic controller shared by all `InplaceEditComponent`s. It dynamically resolves the model but only allows models that are registered in the `UpdateRegistry`. -* The edit action replaces the display component with the edit component via Turbo Stream. -* The update action delegates persistence to a registered handler and then replaces the component. -* The reset action switches back to display mode without saving. +* The `edit` action replaces the display component with the edit component via Turbo Stream. +* The `update` action delegates persistence to a registered handler and then replaces the component. +* The `reset` action switches back to display mode without saving. +* The `dialog` action renders the `InplaceEditFieldDialogComponent` for lazy dialog loading. The controller itself contains no model-specific logic. +**Strong Parameters and `custom_comments`:** + +The controller's `permitted_params` method handles two cases: + +1. **Custom fields via `custom_field_values`** (legacy `fields_for` approach): the value is extracted manually from the raw params because Strong Parameters does not support dynamic hash keys. +2. **Regular attributes**: permitted via `params.expect(model_key => [attribute])`. + +In both cases, `custom_comments` is extracted alongside the field value. Because the comment field name uses a dynamic key (`model[custom_comments][id]`), it is also extracted manually and merged into the permitted params as `{ custom_comments: { "18" => "..." } }`. + #### UpdateRegistry The `UpdateRegistry` maps models to update handlers and contracts. The handler performs the update, while the contract is responsible for authorization and validation. @@ -187,11 +263,15 @@ end ## Adding new fields -To add a new editable attribute, create an `EditFieldComponent` and register it in the `FieldRegistry`. Optionally provide a display component. +To add a new editable attribute: + +1. Create an `EditFieldComponent` that inherits from `BaseFieldComponent`. +2. Call `comment_field_if_enabled(form)` in `call` if the field should support custom field comments. +3. Register it in the `FieldRegistry`. Optionally provide a display component. No changes to the core component or controller should be required. ## Supporting new models To support a new model, implement an update handler and a contract and register both in the `UpdateRegistry`. -No changes to the core component or controller should be required. +No changes to the core component or controller should be required. \ No newline at end of file From c934aa7f918620ead167ca5f910fefccd446c211 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 11 Mar 2026 09:59:39 +0100 Subject: [PATCH 077/435] Avoid click handling when marking text && improve keyboard navigation --- .../display_fields/display_field_component.rb | 32 +++++++++++++------ .../dynamic/inplace-edit.controller.ts | 12 ++++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index d49262d10d0..92570099329 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -73,7 +73,9 @@ module OpenProject def base_arguments { classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable?}", - id: @system_arguments[:id] + id: @system_arguments[:id], + role: "button", + tabindex: 0 } end @@ -82,10 +84,7 @@ module OpenProject data: { controller: "inplace-edit async-dialog", inplace_edit_dialog_url_value: @system_arguments[:dialog_url], - action: "click->inplace-edit#open " \ - "keydown.enter->inplace-edit#open " \ - "keydown.space->inplace-edit#open " \ - "inplace-edit:open-dialog->async-dialog#handleOpenDialog", + action: dialog_controller_actions, test_selector: @system_arguments[:dialog_test_selector] }, aria: { @@ -93,9 +92,7 @@ module OpenProject I18n.t(:label_edit_x, x: @system_arguments[:label]), I18n.t(:label_value_x, x: render_display_value) ].join(", ") - }, - role: "button", - tabindex: 0 + } } end @@ -104,7 +101,7 @@ module OpenProject data: { controller: "inplace-edit", inplace_edit_url_value: edit_url, - action: writable? ? "click->inplace-edit#request" : "" + action: inline_controller_actions } } end @@ -157,6 +154,23 @@ module OpenProject ) .to_a end + + def dialog_controller_actions + return "" unless writable? + + "click->inplace-edit#openDialog " \ + "keydown.enter->inplace-edit#openDialog " \ + "keydown.space->inplace-edit#openDialog " \ + "inplace-edit:open-dialog->async-dialog#handleOpenDialog" + end + + def inline_controller_actions + return "" unless writable? + + "click->inplace-edit#request " \ + "keydown.enter->inplace-edit#request " \ + "keydown.space->inplace-edit#request" + end end end end diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index bb667e03ae9..6ce73b8d853 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -43,6 +43,11 @@ export default class extends Controller { declare hasDialogUrlValue:boolean; async request(e:Event):Promise { + // Don't trigger edit mode if the user is selecting text or just finished a selection + if (window.getSelection()?.toString()) { + return; + } + // Don't trigger edit mode if clicking on a link const target = e.target as HTMLElement; if (target.tagName === 'a' || target.closest('a')) { @@ -62,7 +67,12 @@ export default class extends Controller { } } - open(event:Event) { + openDialog(event:Event) { + // Don't trigger edit mode if the user is selecting text or just finished a selection + if (window.getSelection()?.toString()) { + return; + } + const target = event.target as HTMLElement; // Check if the event is on an interactive element that should be ignored From fcc74a738e311c38ffcb15f148bfe03fe9d0e211 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 9 Mar 2026 14:57:58 +0100 Subject: [PATCH 078/435] Make banners smaller so that they do not block important content --- Gemfile | 2 +- Gemfile.lock | 6 ++-- app/views/layouts/base.html.erb | 2 +- app/views/layouts/only_logo.html.erb | 2 +- frontend/package-lock.json | 28 +++++++++---------- frontend/package.json | 4 +-- .../src/global_styles/content/_toast.sass | 17 ++--------- .../global_styles/openproject/_mixins.sass | 19 +++++++++++++ frontend/src/global_styles/primer/_flash.sass | 9 +----- 9 files changed, 45 insertions(+), 44 deletions(-) diff --git a/Gemfile b/Gemfile index 07235d3d500..455bc8adf2f 100644 --- a/Gemfile +++ b/Gemfile @@ -433,4 +433,4 @@ end gem "openproject-octicons", "~>19.32.0" gem "openproject-octicons_helper", "~>19.32.0" -gem "openproject-primer_view_components", "~>0.82.0" +gem "openproject-primer_view_components", "~>0.82.1" diff --git a/Gemfile.lock b/Gemfile.lock index 78c9dd2e86b..19e4561c57d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -895,7 +895,7 @@ GEM actionview openproject-octicons (= 19.32.1) railties - openproject-primer_view_components (0.82.0) + openproject-primer_view_components (0.82.1) actionview (>= 7.2.0) activesupport (>= 7.2.0) openproject-octicons (>= 19.30.1) @@ -1679,7 +1679,7 @@ DEPENDENCIES openproject-octicons (~> 19.32.0) openproject-octicons_helper (~> 19.32.0) openproject-openid_connect! - openproject-primer_view_components (~> 0.82.0) + openproject-primer_view_components (~> 0.82.1) openproject-recaptcha! openproject-reporting! openproject-storages! @@ -2059,7 +2059,7 @@ CHECKSUMS openproject-octicons (19.32.1) sha256=32253f3256ad4e1aec36442558ce140623c01e5241d9b90f6eb6d317f462781e openproject-octicons_helper (19.32.1) sha256=7676059927ae940170fb13d62f88b885985a3f0d483e1bb246475afcffd90f8f openproject-openid_connect (1.0.0) - openproject-primer_view_components (0.82.0) sha256=c3d61578d26e6fa6e4bfaf520345de01cc09b487c9841de78494017aadd65ae2 + openproject-primer_view_components (0.82.1) sha256=99d9a8efae6dd2d9976bfbb17f15d8423099412537392ca596ea3ca17b3dbfdc openproject-recaptcha (1.0.0) openproject-reporting (1.0.0) openproject-storages (1.0.0) diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 96f97626981..e5c13a78532 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -56,6 +56,7 @@ See COPYRIGHT and LICENSE files for more details. +<%= render partial: "layouts/flashes" %> <%= I18n.t("open_link_in_a_new_tab") %> @@ -132,7 +133,6 @@ See COPYRIGHT and LICENSE files for more details.
<%= content_tag :main, id: "content-wrapper", class: initial_classes, data: stimulus_content_data do %> - <%= render partial: "layouts/flashes" %> <% if show_onboarding_modal? %>
+ <%= render partial: "layouts/flashes" %>
">
- <%= render partial: "layouts/flashes" %> <%= yield %>
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6cc17786b5..5c2ddeac23a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -56,12 +56,12 @@ "@ng-select/ng-select": "^20.1.0", "@ngneat/content-loader": "^7.0.0", "@openproject/octicons-angular": "^19.32.0", - "@openproject/primer-view-components": "^0.82.0", + "@openproject/primer-view-components": "^0.82.1", "@openproject/reactivestates": "^3.0.1", "@primer/css": "^22.1.0", "@primer/live-region-element": "^0.8.0", "@primer/primitives": "^11.3.2", - "@primer/view-components": "npm:@openproject/primer-view-components@^0.82.0", + "@primer/view-components": "npm:@openproject/primer-view-components@^0.82.1", "@rails/request.js": "^0.0.13", "@stimulus-components/auto-submit": "^6.0.0", "@stimulus-components/reveal": "^5.0.0", @@ -7412,9 +7412,9 @@ } }, "node_modules/@openproject/primer-view-components": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.0.tgz", - "integrity": "sha512-i7rXa5Fsf1IY//txycIUQjzhIBSGGpL8g76AQvrEzPUQ54okFt8xLmUzqBWzQiU4MWpsPYLTc19ZXK5mvfQAbw==", + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.1.tgz", + "integrity": "sha512-3OYWzZgvm8SSWZBYwPRa2kCTew6q3QEIEh44Kk/rXfqHoiT7kG7geOMkQ363M6MxTbCsJh5ol8bsa+pBnwZ7fw==", "license": "MIT", "dependencies": { "@github/auto-check-element": "^6.0.0", @@ -7816,9 +7816,9 @@ }, "node_modules/@primer/view-components": { "name": "@openproject/primer-view-components", - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.0.tgz", - "integrity": "sha512-i7rXa5Fsf1IY//txycIUQjzhIBSGGpL8g76AQvrEzPUQ54okFt8xLmUzqBWzQiU4MWpsPYLTc19ZXK5mvfQAbw==", + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.1.tgz", + "integrity": "sha512-3OYWzZgvm8SSWZBYwPRa2kCTew6q3QEIEh44Kk/rXfqHoiT7kG7geOMkQ363M6MxTbCsJh5ol8bsa+pBnwZ7fw==", "license": "MIT", "dependencies": { "@github/auto-check-element": "^6.0.0", @@ -30366,9 +30366,9 @@ } }, "@openproject/primer-view-components": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.0.tgz", - "integrity": "sha512-i7rXa5Fsf1IY//txycIUQjzhIBSGGpL8g76AQvrEzPUQ54okFt8xLmUzqBWzQiU4MWpsPYLTc19ZXK5mvfQAbw==", + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.1.tgz", + "integrity": "sha512-3OYWzZgvm8SSWZBYwPRa2kCTew6q3QEIEh44Kk/rXfqHoiT7kG7geOMkQ363M6MxTbCsJh5ol8bsa+pBnwZ7fw==", "requires": { "@github/auto-check-element": "^6.0.0", "@github/auto-complete-element": "^3.8.0", @@ -30561,9 +30561,9 @@ "integrity": "sha512-/8EDh3MmF9cbmrLETFmIuNFIdvpSCkvBlx6zzD8AZ4dZ5UYExQzFj8QAtIrRtCFJ2ZmW5QrtrPR3+JVb8KEDpg==" }, "@primer/view-components": { - "version": "npm:@openproject/primer-view-components@0.82.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.0.tgz", - "integrity": "sha512-i7rXa5Fsf1IY//txycIUQjzhIBSGGpL8g76AQvrEzPUQ54okFt8xLmUzqBWzQiU4MWpsPYLTc19ZXK5mvfQAbw==", + "version": "npm:@openproject/primer-view-components@0.82.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.82.1.tgz", + "integrity": "sha512-3OYWzZgvm8SSWZBYwPRa2kCTew6q3QEIEh44Kk/rXfqHoiT7kG7geOMkQ363M6MxTbCsJh5ol8bsa+pBnwZ7fw==", "requires": { "@github/auto-check-element": "^6.0.0", "@github/auto-complete-element": "^3.8.0", diff --git a/frontend/package.json b/frontend/package.json index 42bab64df40..fe4cb39b90e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -111,12 +111,12 @@ "@ng-select/ng-select": "^20.1.0", "@ngneat/content-loader": "^7.0.0", "@openproject/octicons-angular": "^19.32.0", - "@openproject/primer-view-components": "^0.82.0", + "@openproject/primer-view-components": "^0.82.1", "@openproject/reactivestates": "^3.0.1", "@primer/css": "^22.1.0", "@primer/live-region-element": "^0.8.0", "@primer/primitives": "^11.3.2", - "@primer/view-components": "npm:@openproject/primer-view-components@^0.82.0", + "@primer/view-components": "npm:@openproject/primer-view-components@^0.82.1", "@rails/request.js": "^0.0.13", "@stimulus-components/auto-submit": "^6.0.0", "@stimulus-components/reveal": "^5.0.0", diff --git a/frontend/src/global_styles/content/_toast.sass b/frontend/src/global_styles/content/_toast.sass index baf8b0994af..1a3475ba3a6 100644 --- a/frontend/src/global_styles/content/_toast.sass +++ b/frontend/src/global_styles/content/_toast.sass @@ -33,8 +33,7 @@ $nm-font-size: var(--body-font-size) $nm-border-radius: rem-calc(2px) -$nm-box-padding: rem-calc(10px 35px 10px 35px) -$nm-toaster-width: rem-calc(550) +$nm-box-padding: rem-calc(10px 40px 10px 40px) $nm-color-success-border: var(--borderColor-success-muted) $nm-color-success-icon: var(--fgColor-success) @@ -100,14 +99,13 @@ $nm-upload-box-padding: rem-calc(15) rem-calc(25) left: $left font-size: $size - - .op-toast @include spot-z-index("toast") display: flex box-shadow: rem-calc(1px 2px 3px) rgba(0, 0, 0, 0.2) border: rem-calc(1px) solid $nm-color-border + border-radius: var(--borderRadius-medium) font-size: $nm-font-size word-wrap: break-word position: relative @@ -217,16 +215,7 @@ $nm-upload-box-padding: rem-calc(15) rem-calc(25) color: $nm-color-info-icon .op-toast--wrapper - position: absolute - max-width: $nm-toaster-width - margin: 0 auto - left: 10% - right: 10% - -.op-toast--wrapper - // Higher than loading indicator and modal wrapper! - z-index: 10000 - top: 4rem + @include banner-styles .op-toast--casing position: relative diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass index 1e90efcd307..24975962cdd 100644 --- a/frontend/src/global_styles/openproject/_mixins.sass +++ b/frontend/src/global_styles/openproject/_mixins.sass @@ -89,6 +89,25 @@ @mixin widget-box--hover-style box-shadow: 0px 1px 20px 0px rgba(0,0,0,0.1) +@mixin banner-styles + position: absolute + max-width: 33% + min-width: 33% + margin: 0 auto + left: 0 + right: 0 + // Higher than loading indicator and modal wrapper! + z-index: 10000 + top: 4rem + + @media screen and (max-width: $breakpoint-md) + max-width: 50% + min-width: 50% + + @media screen and (max-width: $breakpoint-sm) + max-width: calc(100% - 2rem) + min-width: calc(100% - 2rem) + // These mixins are necessary so that other classes can inherit the styles. // In future Sass versions a doubled inheritance like @extend classA.classB will not work any more. // See https://github.com/sass/sass/issues/1599 diff --git a/frontend/src/global_styles/primer/_flash.sass b/frontend/src/global_styles/primer/_flash.sass index 491126faac0..e1617e24409 100644 --- a/frontend/src/global_styles/primer/_flash.sass +++ b/frontend/src/global_styles/primer/_flash.sass @@ -26,16 +26,9 @@ // See COPYRIGHT and LICENSE files for more details. //++ -$op-primer-flash-toaster-width: 80% - .op-primer-flash + @include banner-styles z-index: 599 - position: absolute - max-width: $op-primer-flash-toaster-width - margin: 0 auto - left: 10% - right: 10% - top: 1rem background-color: var(--body-background) // Align multiple toasts From 04f03914c20d23d13d4ed3c67513c6992af110c0 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:10:57 +0100 Subject: [PATCH 079/435] Use readonly instead of disabled for a11y --- app/components/users/non_working_times/form_component.html.erb | 2 +- app/components/users/working_hours/availability_factor_form.rb | 2 +- app/components/users/working_hours/days_and_hours_form.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/users/non_working_times/form_component.html.erb b/app/components/users/non_working_times/form_component.html.erb index cb2d3b4eb1c..4bce7ca1eea 100644 --- a/app/components/users/non_working_times/form_component.html.erb +++ b/app/components/users/non_working_times/form_component.html.erb @@ -40,7 +40,7 @@ g.text_field( name: :working_days_display, label: I18n.t(:label_working_days), - disabled: true, + readonly: true, value: model.working_days_count, datepicker_options: { inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID }, data: { "users--non-working-times-form-target": "workingDaysInput" } diff --git a/app/components/users/working_hours/availability_factor_form.rb b/app/components/users/working_hours/availability_factor_form.rb index d44066e773b..9dd3069bff9 100644 --- a/app/components/users/working_hours/availability_factor_form.rb +++ b/app/components/users/working_hours/availability_factor_form.rb @@ -57,7 +57,7 @@ class Users::WorkingHours::AvailabilityFactorForm < ApplicationForm form.text_field name: :total_factored_hours, label: I18n.t("users.working_hours.form.total_available_hours"), input_width: :large, - disabled: true, + readonly: true, data: { "users--working-hours-form-target": "totalAvailableHoursDisplay" }, trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_week") } } end diff --git a/app/components/users/working_hours/days_and_hours_form.rb b/app/components/users/working_hours/days_and_hours_form.rb index 2bb0520c9ce..c3573097df5 100644 --- a/app/components/users/working_hours/days_and_hours_form.rb +++ b/app/components/users/working_hours/days_and_hours_form.rb @@ -98,7 +98,7 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm form.text_field name: :total_work_hours, label: I18n.t("users.working_hours.form.total_work_hours"), input_width: :large, - disabled: true, + readonly: true, data: { "users--working-hours-form-target": "totalWorkHoursDisplay" }, trailing_visual: { text: { text: I18n.t("users.working_hours.form.per_week") } } end From 72307a0a272482a8a9be7f153d8de6a1afa2a956 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:11:49 +0100 Subject: [PATCH 080/435] Update app/components/users/non_working_times/year_overview_component.rb Co-authored-by: Henriette Darge --- .../users/non_working_times/year_overview_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/users/non_working_times/year_overview_component.rb b/app/components/users/non_working_times/year_overview_component.rb index 23a5f7f034f..b69ab683645 100644 --- a/app/components/users/non_working_times/year_overview_component.rb +++ b/app/components/users/non_working_times/year_overview_component.rb @@ -44,7 +44,7 @@ module Users render(Users::NonWorkingTimes::SubHeaderComponent.new(year:, user:)) + render(Primer::Alpha::Layout.new(classes: "users-non-working-times-year-overview")) do |layout| layout.with_main { render_calendar } - layout.with_sidebar(col_placement: :end) { render_sidebar } + layout.with_sidebar(col_placement: :end, width: :wide) { render_sidebar } end end From 0ae690292dc7e042873a7b685fbdaffa176571f5 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:13:53 +0100 Subject: [PATCH 081/435] Add missing title for working time pages in accounts area --- app/views/my/non_working_times.html.erb | 30 ++++++++++++++++++++++++ app/views/my/working_hours.html.erb | 31 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/app/views/my/non_working_times.html.erb b/app/views/my/non_working_times.html.erb index dd262a23e53..128142feb53 100644 --- a/app/views/my/non_working_times.html.erb +++ b/app/views/my/non_working_times.html.erb @@ -1,2 +1,32 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title(t(:label_my_account), t(:label_non_working_days)) -%> <%= render(My::WorkingTimesHeaderComponent.new) %> <%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times, user: @user)) %> diff --git a/app/views/my/working_hours.html.erb b/app/views/my/working_hours.html.erb index 9f5b1a05ea3..4ae46491ce1 100644 --- a/app/views/my/working_hours.html.erb +++ b/app/views/my/working_hours.html.erb @@ -1,3 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title(t(:label_my_account), t(:label_working_hours)) -%> + <%= render(My::WorkingTimesHeaderComponent.new) %> <%= render(Users::WorkingHours::CurrentScheduleComponent.new(working_hours: @current_working_hours, user: User.current)) %> From dfd34db16fd01f75501c6391bb1d8f9043241be6 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:16:00 +0100 Subject: [PATCH 082/435] Remove disabled edit button, when you cannot edit --- .../users/working_hours/current_schedule_component.html.erb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/components/users/working_hours/current_schedule_component.html.erb b/app/components/users/working_hours/current_schedule_component.html.erb index 2494bdfac4a..9f317baf9b6 100644 --- a/app/components/users/working_hours/current_schedule_component.html.erb +++ b/app/components/users/working_hours/current_schedule_component.html.erb @@ -42,8 +42,6 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "async-dialog" } ) ) %> - <% else %> - <%= render(Primer::Beta::IconButton.new(icon: :pencil, disabled: true, "aria-label": t("button_edit"))) %> <% end %> <% end %> From e40f89413d9c45b2241289f12d9c4f231dc1b73e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:19:29 +0100 Subject: [PATCH 083/435] Remove custom styling and use classes --- .../users/working_hours/current_schedule_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/users/working_hours/current_schedule_component.html.erb b/app/components/users/working_hours/current_schedule_component.html.erb index 9f317baf9b6..7b358252c7d 100644 --- a/app/components/users/working_hours/current_schedule_component.html.erb +++ b/app/components/users/working_hours/current_schedule_component.html.erb @@ -45,7 +45,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> - <%= render(Primer::Box.new(display: :flex, style: "gap: 12px; flex-wrap: wrap;")) do %> + <%= render(Primer::Box.new(display: :flex, classes: "gap-3")) do %> <%= render( Users::WorkingHours::StatCardComponent.new( label: t("users.working_hours.current_schedule.work_days"), From 6bd3bcf8ccb314539f83d94820a5d088ec66955f Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:22:23 +0100 Subject: [PATCH 084/435] Conditional rendering for spacious subhead --- .../users/working_hours/days_and_hours_form.rb | 10 ++++++++-- .../users/working_hours/form_component.html.erb | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/components/users/working_hours/days_and_hours_form.rb b/app/components/users/working_hours/days_and_hours_form.rb index c3573097df5..b09e2aa3c98 100644 --- a/app/components/users/working_hours/days_and_hours_form.rb +++ b/app/components/users/working_hours/days_and_hours_form.rb @@ -29,9 +29,14 @@ #++ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm + def initialize(is_first_form:) + super() + @is_first_form = is_first_form + end + form do |form| form.html_content do - render(Primer::Beta::Subhead.new(spacious: true)) do |component| + render(Primer::Beta::Subhead.new(spacious: !@is_first_form)) do |component| component.with_heading(tag: :div, size: :medium) do I18n.t("users.working_hours.form.title_days_and_hours") end @@ -53,7 +58,8 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm end end - form.radio_button_group(name: "hours_mode", label: I18n.t("users.working_hours.form.hours_mode_label"), mb: 2) do |group| + form.radio_button_group(name: "hours_mode", label: I18n.t("users.working_hours.form.hours_mode_label"), + mb: 2) do |group| group.radio_button( label: I18n.t("users.working_hours.form.same_hours_mode"), value: "same", diff --git a/app/components/users/working_hours/form_component.html.erb b/app/components/users/working_hours/form_component.html.erb index 2af4b2f9ebf..ddeed680cfe 100644 --- a/app/components/users/working_hours/form_component.html.erb +++ b/app/components/users/working_hours/form_component.html.erb @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details. <% if show_valid_from %> <%= render(Users::WorkingHours::ValidFromForm.new(f)) %> <% end %> - <%= render(Users::WorkingHours::DaysAndHoursForm.new(f)) %> + <%= render(Users::WorkingHours::DaysAndHoursForm.new(f, is_first_form: !show_valid_from)) %> <%= render(Users::WorkingHours::AvailabilityFactorForm.new(f)) %> <% end %> <% end %> From 993b3e53f07af7afbd41e83f9cf62425d23cf53c Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 26 Feb 2026 17:57:43 +0100 Subject: [PATCH 085/435] add a sprint GET end point to v3 --- config/locales/en.yml | 1 + lib/api/v3/workspaces/linked_resource.rb | 2 +- modules/backlogs/app/models/agile/sprint.rb | 4 + .../models/agile/sprints/scopes/visible.rb | 45 +++++ modules/backlogs/config/locales/en.yml | 4 + .../lib/api/v3/sprints/sprint_representer.rb | 72 ++++++++ .../lib/api/v3/sprints/sprints_api.rb | 49 ++++++ .../lib/open_project/backlogs/engine.rb | 8 + .../sprint_representer_rendering_spec.rb | 156 ++++++++++++++++++ .../agile/sprints/scopes/visible_spec.rb | 105 ++++++++++++ .../api/v3/sprints/show_resource_spec.rb | 80 +++++++++ 11 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 modules/backlogs/app/models/agile/sprints/scopes/visible.rb create mode 100644 modules/backlogs/lib/api/v3/sprints/sprint_representer.rb create mode 100644 modules/backlogs/lib/api/v3/sprints/sprints_api.rb create mode 100644 modules/backlogs/spec/lib/api/v3/sprints/sprint_representer_rendering_spec.rb create mode 100644 modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb create mode 100644 modules/backlogs/spec/requests/api/v3/sprints/show_resource_spec.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index 2d789e00cc8..b219cf61e6f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5706,6 +5706,7 @@ en: project: Undisclosed - The project is invisible because of lacking permissions. ancestor: Undisclosed - The ancestor is invisible because of lacking permissions. definingProject: Undisclosed - The project is invisible because of lacking permissions. + definingWorkspace: Undisclosed - The workspace is invisible because of lacking permissions. doorkeeper: pre_authorization: diff --git a/lib/api/v3/workspaces/linked_resource.rb b/lib/api/v3/workspaces/linked_resource.rb index 6a22481c8aa..17613907628 100644 --- a/lib/api/v3/workspaces/linked_resource.rb +++ b/lib/api/v3/workspaces/linked_resource.rb @@ -70,7 +70,7 @@ module API representer: ::API::V3::Projects::ProjectRepresenter, skip_render:, link: ::API::V3::Workspaces::WorkspaceRepresenterFactory - .create_link_lambda(name), + .create_link_lambda(name, property_name: as), setter: ::API::V3::Workspaces::WorkspaceRepresenterFactory .create_setter_lambda(name) } diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index 8905ffd709d..f757080dd0d 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -76,6 +76,10 @@ module Agile validate :validate_only_one_active_sprint_per_project + include ::Scopes::Scoped + + scopes :visible + # TODO: validate sharing is set to an allowed value, e.g. only admins may share systemwide (#71374, #71253) # TODO: implement sharing logic once it has been defined (#71374) diff --git a/modules/backlogs/app/models/agile/sprints/scopes/visible.rb b/modules/backlogs/app/models/agile/sprints/scopes/visible.rb new file mode 100644 index 00000000000..cc3ee1385b2 --- /dev/null +++ b/modules/backlogs/app/models/agile/sprints/scopes/visible.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Agile::Sprints::Scopes + module Visible + extend ActiveSupport::Concern + + class_methods do + # Returns all sprints the user is allowed to see. + # A sprint is visible if the user has the :view_sprints permission + # in the project the sprint belongs to. + def visible(user = User.current) + joins(:project) + .merge(Project.allowed_to(user, :view_sprints)) + end + end + end +end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 3d26e704927..a5ffa720469 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -40,6 +40,10 @@ en: goal: "Sprint goal" name: "Sprint name" sharing: "Sharing" + statuses: + in_planning: "In planning" + active: "Active" + completed: "Completed" sprint: duration: "Sprint duration" work_package: diff --git a/modules/backlogs/lib/api/v3/sprints/sprint_representer.rb b/modules/backlogs/lib/api/v3/sprints/sprint_representer.rb new file mode 100644 index 00000000000..0cd185cbb6f --- /dev/null +++ b/modules/backlogs/lib/api/v3/sprints/sprint_representer.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "roar/decorator" +require "roar/json/hal" + +module API + module V3 + module Sprints + class SprintRepresenter < ::API::Decorators::Single + include API::Decorators::LinkedResource + include API::V3::Workspaces::LinkedResource + include API::Decorators::DateProperty + + self_link + + link :status do + { + href: "#{::API::V3::URN_PREFIX}sprints:status:#{represented.status}", + title: I18n.t("activerecord.attributes.agile/sprint.statuses.#{represented.status}") + } + end + + associated_project as: :definingWorkspace + + property :id, + render_nil: true + + property :name, + render_nil: true + + date_property :start_date + + date_property :finish_date + + date_time_property :created_at + date_time_property :updated_at + + def _type + "Sprint" + end + end + end + end +end diff --git a/modules/backlogs/lib/api/v3/sprints/sprints_api.rb b/modules/backlogs/lib/api/v3/sprints/sprints_api.rb new file mode 100644 index 00000000000..f79213a4aa5 --- /dev/null +++ b/modules/backlogs/lib/api/v3/sprints/sprints_api.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module Sprints + class SprintsAPI < ::API::OpenProjectAPI + resources :sprints do + route_param :id, type: Integer, desc: "Sprint ID" do + after_validation do + guard_feature_flag(:scrum_projects) + + @sprint = Agile::Sprint.visible(current_user).find(params[:id]) + end + + get &::API::V3::Utilities::Endpoints::Show.new(model: Agile::Sprint).mount + end + end + end + end + end +end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index f259da89eeb..e3b80d3333e 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -180,6 +180,14 @@ module OpenProject::Backlogs "#{root}/backlogs_types/#{id}" end + add_api_path :sprint do |id| + "#{root}/sprints/#{id}" + end + + add_api_endpoint "API::V3::Root" do + mount ::API::V3::Sprints::SprintsAPI + end + config.to_prepare do OpenProject::Backlogs::Hooks::LayoutHook OpenProject::Backlogs::Hooks::UserSettingsHook diff --git a/modules/backlogs/spec/lib/api/v3/sprints/sprint_representer_rendering_spec.rb b/modules/backlogs/spec/lib/api/v3/sprints/sprint_representer_rendering_spec.rb new file mode 100644 index 00000000000..155a6911273 --- /dev/null +++ b/modules/backlogs/spec/lib/api/v3/sprints/sprint_representer_rendering_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe API::V3::Sprints::SprintRepresenter, "rendering" do + include API::V3::Utilities::PathHelper + + let(:workspace) { build_stubbed(:project) } + let(:start_date) { Date.new(2024, 1, 1) } + let(:finish_date) { Date.new(2024, 1, 10) } + let(:status) { "in_planning" } + let(:sprint) do + build_stubbed(:agile_sprint, + project: workspace, + status:, + name: "Sprint 1", + start_date:, + finish_date:) + end + let(:current_user) { build_stubbed(:user) } + let(:embed_links) { true } + let(:representer) { described_class.create(sprint, current_user:, embed_links:) } + + subject(:generated) { representer.to_json } + + it { is_expected.to include_json("Sprint".to_json).at_path("_type") } + + describe "links" do + it { is_expected.to have_json_type(Object).at_path("_links") } + + describe "self" do + it_behaves_like "has a titled link" do + let(:link) { "self" } + let(:href) { api_v3_paths.sprint(sprint.id) } + let(:title) { sprint.name } + end + end + + describe "definingWorkspace" do + it_behaves_like "has workspace linked" do + let(:link) { "definingWorkspace" } + end + end + + describe "status" do + let(:link) { "status" } + + context "with in_planning value" do + it_behaves_like "has a titled link" do + let(:href) { "urn:openproject-org:api:v3:sprints:status:in_planning" } + let(:title) { I18n.t("activerecord.attributes.agile/sprint.statuses.in_planning") } + end + end + + context "with active value" do + let(:status) { "active" } + + it_behaves_like "has a titled link" do + let(:href) { "urn:openproject-org:api:v3:sprints:status:active" } + let(:title) { I18n.t("activerecord.attributes.agile/sprint.statuses.active") } + end + end + + context "with completed value" do + let(:status) { "completed" } + + it_behaves_like "has a titled link" do + let(:href) { "urn:openproject-org:api:v3:sprints:status:completed" } + let(:title) { I18n.t("activerecord.attributes.agile/sprint.statuses.completed") } + end + end + end + end + + describe "properties" do + describe "_type" do + it_behaves_like "property", :_type do + let(:value) { "Sprint" } + end + end + + describe "id" do + it_behaves_like "property", :id do + let(:value) { sprint.id } + end + end + + describe "name" do + it_behaves_like "property", :name do + let(:value) { sprint.name } + end + end + + describe "startDate" do + it_behaves_like "has ISO 8601 date only" do + let(:date) { start_date } + let(:json_path) { "startDate" } + end + end + + describe "finishDate" do + it_behaves_like "has ISO 8601 date only" do + let(:date) { finish_date } + let(:json_path) { "finishDate" } + end + end + + describe "createdAt" do + it_behaves_like "has UTC ISO 8601 date and time" do + let(:date) { sprint.created_at } + let(:json_path) { "createdAt" } + end + end + + describe "updatedAt" do + it_behaves_like "has UTC ISO 8601 date and time" do + let(:date) { sprint.updated_at } + let(:json_path) { "updatedAt" } + end + end + end + + describe "embedded" do + it_behaves_like "has workspace embedded" do + let(:embedded_path) { "_embedded/definingWorkspace" } + end + end +end diff --git a/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb b/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb new file mode 100644 index 00000000000..29ac3450c4e --- /dev/null +++ b/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Agile::Sprints::Scopes::Visible do + shared_let(:project) { create(:project) } + shared_let(:other_project) { create(:project) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:sprint_in_other_project) { create(:agile_sprint, project: other_project) } + shared_let(:role) { create(:project_role, permissions: [:view_sprints]) } + shared_let(:user_with_permission) do + create(:user).tap do |u| + create(:member, project:, user: u, roles: [role]) + end + end + shared_let(:user_with_permission_in_both) do + create(:user).tap do |u| + create(:member, project:, user: u, roles: [role]) + create(:member, project: other_project, user: u, roles: [role]) + end + end + shared_let(:user_without_permission) do + create(:user).tap do |u| + create(:member, + project:, + user: u, + roles: [create(:project_role, permissions: [:view_work_packages])]) + end + end + shared_let(:user_without_membership) { create(:user) } + + subject { Agile::Sprint.visible(current_user) } + + context "for a user with view_sprints in one project" do + current_user { user_with_permission } + + it "returns the sprint in that project" do + expect(subject).to contain_exactly(sprint) + end + + it "does not return sprints from projects the user has no permission in" do + expect(subject).not_to include(sprint_in_other_project) + end + end + + context "for a user with view_sprnts in both projects" do + current_user { user_with_permission_in_both } + + it "returns sprints from both projects" do + expect(subject).to contain_exactly(sprint, sprint_in_other_project) + end + end + + context "for a user with a different permission but not view_sprints" do + current_user { user_without_permission } + + it "returns no sprints" do + expect(subject).to be_empty + end + end + + context "for a user without any membership" do + current_user { user_without_membership } + + it "returns no sprints" do + expect(subject).to be_empty + end + end + + context "when called without a user argument" do + current_user { user_with_permission } + + it "uses User.current" do + expect(Agile::Sprint.visible).to contain_exactly(sprint) + end + end +end diff --git a/modules/backlogs/spec/requests/api/v3/sprints/show_resource_spec.rb b/modules/backlogs/spec/requests/api/v3/sprints/show_resource_spec.rb new file mode 100644 index 00000000000..40e68c0fb22 --- /dev/null +++ b/modules/backlogs/spec/requests/api/v3/sprints/show_resource_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Sprint resource", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:sprint) { create(:agile_sprint, project:) } + + let(:permissions) { %i[view_sprints] } + + current_user do + create(:user, member_with_permissions: { project => permissions }) + end + + describe "GET /api/v3/sprints/:id", with_flag: :scrum_projects do + let(:get_path) { api_v3_paths.sprint(sprint.id) } + + before do + get get_path + end + + context "for a user with view_sprints permission" do + it_behaves_like "successful response", 200, "Sprint" + end + + context "for a user without view_sprints permission" do + let(:permissions) { [] } + + it_behaves_like "not found" + end + + context "for an anonymous user" do + let(:current_user) { User.anonymous } + + it_behaves_like "unauthenticated access" + end + + context "for a sprint that does not exist" do + let(:get_path) { api_v3_paths.sprint(0) } + + it_behaves_like "not found" + end + + context "when the feature flag is turned off", with_flag: { scrum_projects: false } do + it_behaves_like "not found" + end + end +end From 73e26148e01e8f3a9330afe2c1d068bb4dc1000f Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 26 Feb 2026 16:26:25 +0100 Subject: [PATCH 086/435] add a sprints GET end point to v3 --- .../queries/agile/sprints/sprint_query.rb | 38 ++++++ .../backlogs/app/models/queries/sprints.rb | 35 ++++++ .../queries/sprints/filters/project_filter.rb | 51 +++++++++ .../queries/sprints/filters/sprint_filter.rb | 37 ++++++ .../models/queries/sprints/sprint_query.rb | 46 ++++++++ .../sprints/sprint_collection_representer.rb | 38 ++++++ .../lib/api/v3/sprints/sprints_api.rb | 10 +- .../lib/open_project/backlogs/engine.rb | 6 +- .../lib/api/v3/utilities/path_helper_spec.rb | 41 +++++++ .../sprints/sprint_query_integration_spec.rb | 83 ++++++++++++++ .../api/v3/sprints/index_resource_spec.rb | 104 +++++++++++++++++ .../api/v3/utilities/path_helper_examples.rb | 108 ++++++++++++++++++ spec/lib/api/v3/utilities/path_helper_spec.rb | 78 +------------ 13 files changed, 596 insertions(+), 79 deletions(-) create mode 100644 modules/backlogs/app/models/queries/agile/sprints/sprint_query.rb create mode 100644 modules/backlogs/app/models/queries/sprints.rb create mode 100644 modules/backlogs/app/models/queries/sprints/filters/project_filter.rb create mode 100644 modules/backlogs/app/models/queries/sprints/filters/sprint_filter.rb create mode 100644 modules/backlogs/app/models/queries/sprints/sprint_query.rb create mode 100644 modules/backlogs/lib/api/v3/sprints/sprint_collection_representer.rb create mode 100644 modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb create mode 100644 modules/backlogs/spec/models/queries/sprints/sprint_query_integration_spec.rb create mode 100644 modules/backlogs/spec/requests/api/v3/sprints/index_resource_spec.rb create mode 100644 spec/lib/api/v3/utilities/path_helper_examples.rb diff --git a/modules/backlogs/app/models/queries/agile/sprints/sprint_query.rb b/modules/backlogs/app/models/queries/agile/sprints/sprint_query.rb new file mode 100644 index 00000000000..3ed8311a2c2 --- /dev/null +++ b/modules/backlogs/app/models/queries/agile/sprints/sprint_query.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Queries + module Agile + module Sprints + # Alias to allow auto-discovery by ParamsToQueryService for the Agile::Sprint model. + SprintQuery = ::Queries::Sprints::SprintQuery + end + end +end diff --git a/modules/backlogs/app/models/queries/sprints.rb b/modules/backlogs/app/models/queries/sprints.rb new file mode 100644 index 00000000000..bbd59ad4b48 --- /dev/null +++ b/modules/backlogs/app/models/queries/sprints.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Queries::Sprints + ::Queries::Register.register(SprintQuery) do + filter Filters::ProjectFilter + end +end diff --git a/modules/backlogs/app/models/queries/sprints/filters/project_filter.rb b/modules/backlogs/app/models/queries/sprints/filters/project_filter.rb new file mode 100644 index 00000000000..1fa4162f39e --- /dev/null +++ b/modules/backlogs/app/models/queries/sprints/filters/project_filter.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Queries::Sprints::Filters::ProjectFilter < Queries::Sprints::Filters::SprintFilter + def self.key + :defining_workspace + end + + def allowed_values + @allowed_values ||= ::Project.visible.pluck(:id).map { |id| [id, id.to_s] } + end + + def type + :list + end + + def type_strategy + @type_strategy ||= ::Queries::Filters::Strategies::IntegerList.new(self) + end + + def where + operator_strategy.sql_for_field(values, self.class.model.table_name, :project_id) + end +end diff --git a/modules/backlogs/app/models/queries/sprints/filters/sprint_filter.rb b/modules/backlogs/app/models/queries/sprints/filters/sprint_filter.rb new file mode 100644 index 00000000000..167d2481c2e --- /dev/null +++ b/modules/backlogs/app/models/queries/sprints/filters/sprint_filter.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Queries::Sprints::Filters::SprintFilter < Queries::Filters::Base + self.model = ::Agile::Sprint + + def human_name + ::Agile::Sprint.human_attribute_name(name) + end +end diff --git a/modules/backlogs/app/models/queries/sprints/sprint_query.rb b/modules/backlogs/app/models/queries/sprints/sprint_query.rb new file mode 100644 index 00000000000..93395b9d8ae --- /dev/null +++ b/modules/backlogs/app/models/queries/sprints/sprint_query.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Queries + module Sprints + class SprintQuery + include ::Queries::BaseQuery + include ::Queries::UnpersistedQuery + + def self.model + ::Agile::Sprint + end + + def default_scope + ::Agile::Sprint.visible(User.current) + end + end + end +end diff --git a/modules/backlogs/lib/api/v3/sprints/sprint_collection_representer.rb b/modules/backlogs/lib/api/v3/sprints/sprint_collection_representer.rb new file mode 100644 index 00000000000..15dc4054cab --- /dev/null +++ b/modules/backlogs/lib/api/v3/sprints/sprint_collection_representer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Sprints + class SprintCollectionRepresenter < ::API::Decorators::OffsetPaginatedCollection + end + end + end +end diff --git a/modules/backlogs/lib/api/v3/sprints/sprints_api.rb b/modules/backlogs/lib/api/v3/sprints/sprints_api.rb index f79213a4aa5..81c2cd047a5 100644 --- a/modules/backlogs/lib/api/v3/sprints/sprints_api.rb +++ b/modules/backlogs/lib/api/v3/sprints/sprints_api.rb @@ -33,10 +33,16 @@ module API module Sprints class SprintsAPI < ::API::OpenProjectAPI resources :sprints do + before do + guard_feature_flag(:scrum_projects) + end + + get &::API::V3::Utilities::Endpoints::Index + .new(model: Agile::Sprint) + .mount + route_param :id, type: Integer, desc: "Sprint ID" do after_validation do - guard_feature_flag(:scrum_projects) - @sprint = Agile::Sprint.visible(current_user).find(params[:id]) end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index e3b80d3333e..670ab524f0b 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -145,7 +145,7 @@ module OpenProject::Backlogs patch_with_namespace :Versions, :RowComponent config.to_prepare do - next if Versions::BaseContract.included_modules.include?(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) + next if Versions::BaseContract.include?(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) Versions::BaseContract.prepend(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) @@ -184,6 +184,10 @@ module OpenProject::Backlogs "#{root}/sprints/#{id}" end + add_api_path :sprints do + "#{root}/sprints" + end + add_api_endpoint "API::V3::Root" do mount ::API::V3::Sprints::SprintsAPI end diff --git a/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb b/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb new file mode 100644 index 00000000000..f30a02a7fa3 --- /dev/null +++ b/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require_relative Rails.root.join("spec/lib/api/v3/utilities/path_helper_examples").to_s + +RSpec.describe API::V3::Utilities::PathHelper do + include_context "on api v3 paths" + + describe "sprint paths" do + it_behaves_like "index", :sprint + it_behaves_like "show", :sprint + end +end diff --git a/modules/backlogs/spec/models/queries/sprints/sprint_query_integration_spec.rb b/modules/backlogs/spec/models/queries/sprints/sprint_query_integration_spec.rb new file mode 100644 index 00000000000..75f41a930d8 --- /dev/null +++ b/modules/backlogs/spec/models/queries/sprints/sprint_query_integration_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" + +RSpec.describe Queries::Sprints::SprintQuery, "integration" do + shared_let(:project) { create(:project, public: false) } + shared_let(:other_project) { create(:project, public: false) } + shared_let(:project_without_permission) { create(:project, public: false) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:other_sprint) { create(:agile_sprint, project: other_project) } + shared_let(:sprint_without_permission) { create(:agile_sprint, project: project_without_permission) } + + let(:instance) { described_class.new } + let(:permissions) { %i[view_sprints] } + + current_user do + create(:user, + member_with_permissions: { + project => permissions, + other_project => permissions + }) + end + + context "for a user with view_sprints permission" do + it "returns only sprints visible to the user" do + expect(instance.results).to contain_exactly(sprint, other_sprint) + end + end + + context "for a user without view_sprints permission" do + let(:permissions) { [] } + + it "returns no sprints" do + expect(instance.results).to be_empty + end + end + + context "with a defining_workspace filter" do + context "with the = operator" do + before { instance.where("defining_workspace", "=", [project.id.to_s]) } + + it "returns only sprints from the given project" do + expect(instance.results).to contain_exactly(sprint) + end + end + + context "with the ! operator" do + before { instance.where("defining_workspace", "!", [project.id.to_s]) } + + it "returns sprints not belonging to the given project" do + expect(instance.results).to contain_exactly(other_sprint) + end + end + end +end diff --git a/modules/backlogs/spec/requests/api/v3/sprints/index_resource_spec.rb b/modules/backlogs/spec/requests/api/v3/sprints/index_resource_spec.rb new file mode 100644 index 00000000000..75ca1684000 --- /dev/null +++ b/modules/backlogs/spec/requests/api/v3/sprints/index_resource_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Sprint resource", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:other_project) { create(:project, public: false) } + shared_let(:project_without_permission) { create(:project, public: false) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:other_sprint) { create(:agile_sprint, project: other_project) } + shared_let(:sprint_without_permission) { create(:agile_sprint, project: project_without_permission) } + + let(:permissions) { %i[view_sprints] } + + current_user do + create(:user, + member_with_permissions: { + project => permissions, + other_project => permissions + }) + end + + describe "GET /api/v3/sprints", with_flag: :scrum_projects do + let(:get_path) { api_v3_paths.path_for(:sprints, filters:, page_size:, offset:) } + let(:filters) { [] } + let(:page_size) { nil } + let(:offset) { nil } + + before do + get get_path + end + + context "for a user with view_sprints permission" do + it_behaves_like "API V3 collection response", 2, 2, "Sprint" do + let(:elements) { [other_sprint, sprint] } + end + end + + context "for a user without view_sprints permission" do + let(:permissions) { [] } + + it_behaves_like "API V3 collection response", 0, 0, "Sprint" + end + + context "for an anonymous user" do + let(:current_user) { User.anonymous } + + it_behaves_like "unauthenticated access" + end + + context "when the feature flag is turned off", with_flag: { scrum_projects: false } do + it_behaves_like "not found" + end + + context "with a page_size parameter and offset parameter" do + let(:page_size) { 1 } + let(:offset) { 2 } + + it_behaves_like "API V3 collection response", 2, 1, "Sprint" do + let(:elements) { [sprint] } + end + end + + context "with a definingWorkspace filter" do + let(:filters) { [{ definingWorkspace: { operator: "=", values: [project.id.to_s] } }] } + + it_behaves_like "API V3 collection response", 1, 1, "Sprint" do + let(:elements) { [sprint] } + end + end + end +end diff --git a/spec/lib/api/v3/utilities/path_helper_examples.rb b/spec/lib/api/v3/utilities/path_helper_examples.rb new file mode 100644 index 00000000000..bf5607a573e --- /dev/null +++ b/spec/lib/api/v3/utilities/path_helper_examples.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +RSpec.shared_context "on api v3 paths" do + let(:helper) { Class.new.tap { |c| c.extend(API::V3::Utilities::PathHelper) }.api_v3_paths } + + shared_examples_for "path" do |url| + it "provides the path" do + expect(subject).to match(url) + end + + it "prepends the sub uri if configured" do + allow(OpenProject::Configuration).to receive(:rails_relative_url_root) + .and_return("/open_project") + + expect(subject).to match("/open_project#{url}") + end + end + + before do + RequestStore.store[:cached_root_path] = nil + end + + after do + RequestStore.clear! + end + + shared_examples_for "api v3 path" do |url| + it_behaves_like "path", "/api/v3#{url}" + end + + shared_examples_for "index" do |name| + plural_name = name.to_s.pluralize # rubocop:disable RSpec/LeakyLocalVariable + + describe "##{plural_name}" do + subject { helper.send(plural_name) } + + it_behaves_like "api v3 path", "/#{plural_name}" + end + end + + shared_examples_for "show" do |name| + describe "##{name}" do + subject { helper.send(:"#{name}", 42) } + + it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/42" + end + end + + shared_examples_for "create form" do |name| + describe "#create_#{name}_form" do + subject { helper.send(:"create_#{name}_form") } + + it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/form" + end + end + + shared_examples_for "update form" do |name| + describe "##{name}_form" do + subject { helper.send(:"#{name}_form", 42) } + + it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/42/form" + end + end + + shared_examples_for "schema" do |name| + describe "##{name}_schema" do + subject { helper.send(:"#{name}_schema") } + + it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/schema" + end + end + + shared_examples_for "resource" do |name, except: []| + it_behaves_like("index", name) unless except.include?(:index) + it_behaves_like("show", name) unless except.include?(:show) + it_behaves_like("update form", name) unless except.include?(:update_form) + it_behaves_like("create form", name) unless except.include?(:create_form) + it_behaves_like("schema", name) unless except.include?(:schema) + end +end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index a0409b0e8ac..37c15946471 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -29,84 +29,10 @@ #++ require "spec_helper" +require_relative "path_helper_examples" RSpec.describe API::V3::Utilities::PathHelper do - let(:helper) { Class.new.tap { |c| c.extend(API::V3::Utilities::PathHelper) }.api_v3_paths } - - shared_examples_for "path" do |url| - it "provides the path" do - expect(subject).to match(url) - end - - it "prepends the sub uri if configured" do - allow(OpenProject::Configuration).to receive(:rails_relative_url_root) - .and_return("/open_project") - - expect(subject).to match("/open_project#{url}") - end - end - - before do - RequestStore.store[:cached_root_path] = nil - end - - after do - RequestStore.clear! - end - - shared_examples_for "api v3 path" do |url| - it_behaves_like "path", "/api/v3#{url}" - end - - shared_examples_for "index" do |name| - plural_name = name.to_s.pluralize - - describe "##{plural_name}" do - subject { helper.send(plural_name) } - - it_behaves_like "api v3 path", "/#{plural_name}" - end - end - - shared_examples_for "show" do |name| - describe "##{name}" do - subject { helper.send(:"#{name}", 42) } - - it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/42" - end - end - - shared_examples_for "create form" do |name| - describe "#create_#{name}_form" do - subject { helper.send(:"create_#{name}_form") } - - it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/form" - end - end - - shared_examples_for "update form" do |name| - describe "##{name}_form" do - subject { helper.send(:"#{name}_form", 42) } - - it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/42/form" - end - end - - shared_examples_for "schema" do |name| - describe "##{name}_schema" do - subject { helper.send(:"#{name}_schema") } - - it_behaves_like "api v3 path", "/#{name.to_s.pluralize}/schema" - end - end - - shared_examples_for "resource" do |name, except: []| - it_behaves_like("index", name) unless except.include?(:index) - it_behaves_like("show", name) unless except.include?(:show) - it_behaves_like("update form", name) unless except.include?(:update_form) - it_behaves_like("create form", name) unless except.include?(:create_form) - it_behaves_like("schema", name) unless except.include?(:schema) - end + include_context "on api v3 paths" describe "#root" do subject { helper.root } From 747d9528988e45cea7b0a146031de8d0ffcd06a9 Mon Sep 17 00:00:00 2001 From: ulferts Date: Mon, 2 Mar 2026 10:29:18 +0100 Subject: [PATCH 087/435] add sprint as work package property --- .../lib/open_project/backlogs/engine.rb | 14 +- .../patches/api/work_package_representer.rb | 34 +++- .../backlogs/patches/checksum_patch.rb | 45 +++++ .../backlogs/patches/work_package_patch.rb | 6 +- .../checksum_integration_spec.rb | 59 +++++++ ...work_package_representer_rendering_spec.rb | 155 ++++++++++++++++++ 6 files changed, 299 insertions(+), 14 deletions(-) create mode 100644 modules/backlogs/lib/open_project/backlogs/patches/checksum_patch.rb create mode 100644 modules/backlogs/spec/lib/api/v3/work_packages/eager_loading/checksum_integration_spec.rb create mode 100644 modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 670ab524f0b..e3e53361df3 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -143,6 +143,7 @@ module OpenProject::Backlogs patch_with_namespace :WorkPackages, :SetAttributesService patch_with_namespace :WorkPackages, :BaseContract patch_with_namespace :Versions, :RowComponent + patch_with_namespace :API, :V3, :WorkPackages, :EagerLoading, :Checksum config.to_prepare do next if Versions::BaseContract.include?(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) @@ -198,7 +199,7 @@ module OpenProject::Backlogs end config.to_prepare do - ::Type.add_constraint :position, ->(type, project: nil) do + enabled_backlogs_story = ->(type, project: nil) do if project.present? project.backlogs_enabled? && type.story? else @@ -207,14 +208,9 @@ module OpenProject::Backlogs end end - ::Type.add_constraint :story_points, ->(type, project: nil) do - if project.present? - project.backlogs_enabled? && type.story? - else - # Allow globally configuring the attribute if story - type.story? - end - end + ::Type.add_constraint :position, enabled_backlogs_story + ::Type.add_constraint :story_points, enabled_backlogs_story + ::Type.add_constraint :sprint, enabled_backlogs_story ::Type.add_default_mapping(:estimates_and_progress, :story_points) ::Type.add_default_mapping(:other, :position) diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb index ed070ef8d0a..30e2f573f71 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb @@ -37,14 +37,44 @@ module OpenProject::Backlogs property :position, render_nil: true, skip_render: ->(*) do - !(backlogs_enabled? && type && type.passes_attribute_constraint?(:position)) + !(backlogs_enabled? && type&.passes_attribute_constraint?(:position)) end property :story_points, render_nil: true, skip_render: ->(*) do - !(backlogs_enabled? && type && type.passes_attribute_constraint?(:story_points)) + !(backlogs_enabled? && type&.passes_attribute_constraint?(:story_points)) end + + resource :sprint, + link_cache_if: ->(*) { + # TODO: change permission to check for :view_sprints + # TODO: apply the same permission check to position and story_points + current_user.allowed_in_project?(:view_master_backlog, represented.project) + }, + link: ->(*) { + next unless represented.type&.passes_attribute_constraint?(:sprint) + + if represented.sprint.present? + { + href: api_v3_paths.sprint(represented.sprint_id), + title: represented.sprint.name + } + else + { + href: nil + } + end + }, + getter: ->(*) do + if embed_links && + represented.sprint.present? && + represented.type&.passes_attribute_constraint?(:story_points) && + current_user.allowed_in_project?(:view_sprints, represented.project) + ::API::V3::Sprints::SprintRepresenter.create(represented.sprint, current_user:) + end + end, + setter: associated_resource_default_setter(:sprint, :sprint, :sprint) end end end diff --git a/modules/backlogs/lib/open_project/backlogs/patches/checksum_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/checksum_patch.rb new file mode 100644 index 00000000000..16c54fc8929 --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/patches/checksum_patch.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module OpenProject::Backlogs::Patches::ChecksumPatch + extend ActiveSupport::Concern + + included do + singleton_class.prepend PrependedClassMethods + end + + module PrependedClassMethods + protected + + def checksum_associations + super + [:sprint] + end + end +end diff --git a/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb index 7e959a3d486..e9657cc9d3f 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb @@ -80,11 +80,11 @@ module OpenProject::Backlogs::Patches::WorkPackagePatch end def is_task? - backlogs_enabled? && (parent_id && type_id == Task.type && Task.type.present?) + backlogs_enabled? && parent_id && type_id == Task.type && Task.type.present? end def is_impediment? - backlogs_enabled? && (parent_id.nil? && type_id == Task.type && Task.type.present?) + backlogs_enabled? && parent_id.nil? && type_id == Task.type && Task.type.present? end def types @@ -113,7 +113,7 @@ module OpenProject::Backlogs::Patches::WorkPackagePatch end def backlogs_enabled? - !!project.try(:module_enabled?, "backlogs") + project&.backlogs_enabled? end def in_backlogs_type? diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/eager_loading/checksum_integration_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/eager_loading/checksum_integration_spec.rb new file mode 100644 index 00000000000..8e066a19872 --- /dev/null +++ b/modules/backlogs/spec/lib/api/v3/work_packages/eager_loading/checksum_integration_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require_relative Rails.root.join("spec/lib/api/v3/work_packages/eager_loading/eager_loading_mock_wrapper") + +RSpec.describe API::V3::WorkPackages::EagerLoading::Checksum, "integration" do + shared_let(:project) { create(:project) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:other_sprint) { create(:agile_sprint, project:) } + shared_let(:work_package) do + create(:work_package, + project:, + sprint:) + end + + describe ".apply" do + def checksum + EagerLoadingMockWrapper.wrap(described_class, [work_package]).first.cache_checksum + end + + it "produces a different checksum on changes to the sprint id" do + expect { WorkPackage.where(id: work_package.id).update_all(sprint_id: other_sprint.id) } + .to change { checksum } + end + + it "produces a different checksum on changes to the sprint" do + expect { sprint.update_attribute(:updated_at, 10.seconds.from_now) } + .to change { checksum } + end + end +end diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb new file mode 100644 index 00000000000..c7ff81744af --- /dev/null +++ b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" + +# Only tests the links/properties added by the backlogs plugin. It does not retest the properties already +# covered in the core. +RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering" do + include API::V3::Utilities::PathHelper + + let(:work_package) do + build_stubbed(:work_package, + type:, + project:, + story_points:, + position:, + sprint:) + end + let(:type) { story_type } + let(:story_type) { build_stubbed(:type) } + let(:task_type) { build_stubbed(:type) } + let(:project) do + build_stubbed(:project, enabled_module_names: %w[backlogs]) + end + + let(:story_points) { 23 } + let(:position) { 123 } + let(:sprint) { build_stubbed(:agile_sprint) } + let(:current_user) { build_stubbed(:user) } + let(:embed_links) { true } + let(:representer) do + described_class.create(work_package, current_user:, embed_links:) + end + let(:permissions) { %i[view_sprints] } + + subject(:generated) { representer.to_json } + + include_context "eager loaded work package representer" + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [story_type.id.to_s], + "task_type" => task_type.id.to_s) + + mock_permissions_for(current_user) do |mock| + permissions.each do |permission| + mock.allow_in_project(permission, project:) + end + end + end + + describe "properties" do + describe "storyPoints" do + context "when it is a story" do + it_behaves_like "property", :storyPoints do + let(:value) { story_points } + end + end + + context "when it is a task" do + let(:type) { task_type } + + it_behaves_like "no property", :storyPoints + end + end + + describe "position" do + it_behaves_like "property", :position do + let(:value) { position } + end + + context "when it is a task" do + let(:type) { task_type } + + it_behaves_like "no property", :position + end + end + end + + describe "links" do + describe "sprint" do + let(:link) { "sprint" } + let(:href) { api_v3_paths.sprint(sprint.id) } + let(:title) { sprint.name } + + context "when it is a story" do + it_behaves_like "has a titled link" + end + + context "when lacking the permission" do + let(:permissions) { [] } + + it_behaves_like "has no link" + end + + context "when it is a task" do + let(:type) { task_type } + + it_behaves_like "has no link" + end + end + end + + describe "embedded" do + describe "sprint" do + let(:embedded_path) { "_embedded/sprint" } + let(:embedded_resource) { sprint } + let(:embedded_resource_type) { "Sprint" } + + context "when it is a story" do + it_behaves_like "has the resource embedded" + end + + context "when lacking the permission" do + let(:permissions) { [] } + + it_behaves_like "has the resource not embedded" + end + + context "when it is a type" do + let(:type) { task_type } + + it_behaves_like "has the resource not embedded" + end + end + end +end From 1240b066c3873f4a25ae5efdca8432f56f354597 Mon Sep 17 00:00:00 2001 From: ulferts Date: Tue, 3 Mar 2026 17:30:50 +0100 Subject: [PATCH 088/435] work package creation and update including backlogs properties --- app/contracts/work_packages/base_contract.rb | 6 +- .../work_packages/update_contract.rb | 51 ++- ..._package_hierarchy_relations_controller.rb | 3 +- .../work_packages/work_package_representer.rb | 7 +- .../backlogs/backlog_menu_component.html.erb | 2 +- modules/backlogs/config/locales/en.yml | 2 + .../lib/open_project/backlogs/engine.rb | 4 +- .../patches/api/work_package_representer.rb | 6 +- .../backlogs/patches/base_contract_patch.rb | 20 +- .../backlogs/patches/update_contract_patch.rb | 45 +++ .../backlogs/backlog_menu_component_spec.rb | 345 +++++++++++++----- .../work_packages/create_contract_spec.rb | 50 +-- .../work_packages/shared_contract_examples.rb | 169 +++++++++ .../work_packages/update_contract_spec.rb | 91 +++-- .../features/backlogs/context_menu_spec.rb | 4 +- .../features/backlogs/create_story_spec.rb | 2 +- .../spec/features/impediments_spec.rb | 1 - .../spec/features/stories_in_backlog_spec.rb | 2 +- .../spec/features/tasks_on_taskboard_spec.rb | 2 +- ...work_package_representer_rendering_spec.rb | 16 + .../work_packages}/sprint_journaling_spec.rb | 41 +-- .../v3/work_packages/create_resource_spec.rb | 123 +++++++ .../v3/work_packages/update_resource_spec.rb | 148 ++++++++ .../impediments/create_services_spec.rb | 2 +- .../services/stories/create_service_spec.rb | 2 +- .../work_packages/update_contract_spec.rb | 78 +++- .../edit_on_assign_version_permission_spec.rb | 16 +- .../work_package_representer_spec.rb | 28 +- 28 files changed, 984 insertions(+), 282 deletions(-) create mode 100644 modules/backlogs/lib/open_project/backlogs/patches/update_contract_patch.rb create mode 100644 modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb rename {spec/models/work_package => modules/backlogs/spec/models/work_packages}/sprint_journaling_spec.rb (72%) create mode 100644 modules/backlogs/spec/requests/api/v3/work_packages/create_resource_spec.rb create mode 100644 modules/backlogs/spec/requests/api/v3/work_packages/update_resource_spec.rb diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 21c973d7a51..e7107492767 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -46,9 +46,8 @@ module WorkPackages attribute :type_id attribute :priority_id attribute :category_id - # TODO: manage_sprint_items can be removed once the sprint_id is in place. attribute :version_id, - permission: %i(assign_versions manage_sprint_items) do + permission: :assign_versions do validate_version_is_assignable end @@ -137,9 +136,6 @@ module WorkPackages unless: -> { model.type&.replacement_pattern_defined_for?(:subject) } validates :subject, length: { maximum: 255 } - # TODO: add validation, check permission (#71253) - attribute :sprint_id - validates :due_date, date: { after_or_equal_to: :start_date, message: :greater_than_or_equal_to_start_date, diff --git a/app/contracts/work_packages/update_contract.rb b/app/contracts/work_packages/update_contract.rb index 562bf9eba87..87d6bd5c7ab 100644 --- a/app/contracts/work_packages/update_contract.rb +++ b/app/contracts/work_packages/update_contract.rb @@ -32,8 +32,36 @@ module WorkPackages class UpdateContract < BaseContract include UnchangedProject + class << self + def update_allowed?(user:, work_package:) + allowed_in_work_package?(user, work_package, :edit_work_packages) || + allowed_in_project?(user, work_package, :assign_versions) || + allowed_in_project?(user, work_package, :change_work_package_status) || + allowed_in_project?(user, work_package, :manage_subtasks) || + allowed_in_project?(user, work_package, :move_work_packages) + end + + def update_parent_allowed?(user:, work_package:) + allowed_in_project?(user, work_package, :manage_subtasks) + end + + def add_comments_allowed?(user:, work_package:) + allowed_in_work_package?(user, work_package, :add_work_package_comments) + end + + private + + def allowed_in_project?(user, work_package, permission) + user.allowed_in_project?(permission, work_package.project) + end + + def allowed_in_work_package?(user, work_package, permission) + user.allowed_in_work_package?(permission, work_package) + end + end + attribute :lock_version, - permission: %i[edit_work_packages change_work_package_status assign_versions manage_sprint_items manage_subtasks + permission: %i[edit_work_packages change_work_package_status assign_versions manage_subtasks move_work_packages] do if model.lock_version.nil? || model.lock_version_changed? errors.add :base, :error_conflict @@ -51,20 +79,11 @@ module WorkPackages default_attribute_permission :edit_work_packages attribute_permission :project_id, :move_work_packages - def can_set_parent? - allowed_in_project?(:manage_subtasks) - end - private def user_allowed_to_edit with_unchanged_project_id do - next if allowed_in_work_package?(:edit_work_packages) || - allowed_in_project?(:assign_versions) || - allowed_in_project?(:manage_sprint_items) || - allowed_in_project?(:change_work_package_status) || - allowed_in_project?(:manage_subtasks) || - allowed_in_project?(:move_work_packages) + next if self.class.update_allowed?(user:, work_package: model) next if allowed_journal_addition? errors.add :base, :error_unauthorized @@ -78,7 +97,7 @@ module WorkPackages end def allowed_journal_addition? - model.changes.empty? && model.journal_notes && allowed_in_work_package?(:add_work_package_comments) + model.changes.empty? && model.journal_notes && self.class.add_comments_allowed?(user:, work_package: model) end def can_move_to_milestone @@ -97,13 +116,5 @@ module WorkPackages errors.add :parent_id, :error_unauthorized end end - - def allowed_in_project?(permission) - user.allowed_in_project?(permission, model.project) - end - - def allowed_in_work_package?(permission) - user.allowed_in_work_package?(permission, model) - end end end diff --git a/app/controllers/work_package_hierarchy_relations_controller.rb b/app/controllers/work_package_hierarchy_relations_controller.rb index ac48c957ec0..cc6e8f107a3 100644 --- a/app/controllers/work_package_hierarchy_relations_controller.rb +++ b/app/controllers/work_package_hierarchy_relations_controller.rb @@ -121,8 +121,7 @@ class WorkPackageHierarchyRelationsController < ApplicationController end def allowed_to_set_parent?(child) - contract = WorkPackages::UpdateContract.new(child, current_user) - contract.can_set_parent? + WorkPackages::UpdateContract.update_parent_allowed?(work_package: child, user: current_user) end def respond_with_relations_tab_update(service_result, **) diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index 4043e338c45..bd2088b8712 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -654,11 +654,8 @@ module API def current_user_update_allowed? return @current_user_update_allowed if defined?(@current_user_update_allowed) - @current_user_update_allowed = - current_user.allowed_in_work_package?(:edit_work_packages, represented) || - current_user.allowed_in_project?(:change_work_package_status, represented.project) || - current_user.allowed_in_project?(:assign_versions, represented.project) || - current_user.allowed_in_project?(:manage_sprint_items, represented.project) + @current_user_update_allowed = ::WorkPackages::UpdateContract.update_allowed?(user: current_user, + work_package: represented) end def view_time_entries_allowed? diff --git a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb index 944eb46096d..a716f2ab67d 100644 --- a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb @@ -47,7 +47,7 @@ See COPYRIGHT and LICENSE files for more details. end end - if user_allowed?(:manage_sprint_items) + if user_allowed?(:add_work_packages) && user_allowed?(:assign_versions) menu.with_item( id: dom_target(sprint, :menu, :new_story), label: t(".action_menu.new_story"), diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index a5ffa720469..781959c85f3 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -60,6 +60,8 @@ en: must_block_at_least_one_work_package: "must contain the ID of at least one ticket." version_id: task_version_must_be_the_same_as_story_version: "must be the same as the parent story's version." + sprint: + not_shared_with_project: "is not shared with the project the work package is in." sprint: cannot_end_before_it_starts: "Sprint cannot end before it starts." diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index e3e53361df3..c1281958d44 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -142,6 +142,7 @@ module OpenProject::Backlogs patch_with_namespace :WorkPackages, :UpdateService patch_with_namespace :WorkPackages, :SetAttributesService patch_with_namespace :WorkPackages, :BaseContract + patch_with_namespace :WorkPackages, :UpdateContract patch_with_namespace :Versions, :RowComponent patch_with_namespace :API, :V3, :WorkPackages, :EagerLoading, :Checksum @@ -168,14 +169,13 @@ module OpenProject::Backlogs extend_api_response(:v3, :work_packages, :work_package, &::OpenProject::Backlogs::Patches::API::WorkPackageRepresenter.extension) + # TODO: check if this can be simply removed as it already gets its info by patching the WPRepresenter extend_api_response(:v3, :work_packages, :work_package_payload, &::OpenProject::Backlogs::Patches::API::WorkPackageRepresenter.extension) extend_api_response(:v3, :work_packages, :schema, :work_package_schema, &::OpenProject::Backlogs::Patches::API::WorkPackageSchemaRepresenter.extension) - add_api_attribute on: :work_package, ar_name: :story_points - add_api_path :backlogs_type do |id| # There is no api endpoint for this url "#{root}/backlogs_types/#{id}" diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb index 30e2f573f71..65f23232750 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb @@ -32,7 +32,7 @@ module OpenProject::Backlogs module WorkPackageRepresenter module_function - def extension + def extension # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity ->(*) do property :position, render_nil: true, @@ -48,9 +48,7 @@ module OpenProject::Backlogs resource :sprint, link_cache_if: ->(*) { - # TODO: change permission to check for :view_sprints - # TODO: apply the same permission check to position and story_points - current_user.allowed_in_project?(:view_master_backlog, represented.project) + current_user.allowed_in_project?(:view_sprints, represented.project) }, link: ->(*) { next unless represented.type&.passes_attribute_constraint?(:sprint) diff --git a/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb index 70eb1d3aadb..8f42425a02d 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -30,7 +32,21 @@ module OpenProject::Backlogs::Patches::BaseContractPatch extend ActiveSupport::Concern included do - attribute :story_points - attribute :position + attribute :story_points, + writable: -> { model.backlogs_enabled? } + attribute :sprint, + # This also covers the check for backlogs being active + permission: :manage_sprint_items + + validate :sprint_shared_with_project + + private + + def sprint_shared_with_project + return if model.sprint.nil? || + Agile::Sprint.for_project(model.project).exists?(id: model.sprint_id) + + errors.add :sprint, :not_shared_with_project + end end end diff --git a/modules/backlogs/lib/open_project/backlogs/patches/update_contract_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/update_contract_patch.rb new file mode 100644 index 00000000000..41dd3548f30 --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/patches/update_contract_patch.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module OpenProject::Backlogs::Patches::UpdateContractPatch + extend ActiveSupport::Concern + + included do + singleton_class.prepend ClassMethods + + attribute_permission :lock_version, attribute_permissions[:lock_version] + [:manage_sprint_items] + end + + module ClassMethods + def update_allowed?(user:, work_package:) + super || allowed_in_project?(user, work_package, :manage_sprint_items) + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb index d9dfac1d79a..4692544501a 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb @@ -37,7 +37,8 @@ RSpec.describe Backlogs::BacklogMenuComponent, type: :component do let(:project) { create(:project, types: [type_feature, type_task]) } let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: Date.yesterday, effective_date: Date.tomorrow) } let(:stories) { [] } - let(:backlog) { Backlog.new(sprint:, stories:) } + let(:backlog) { Backlog.new(sprint:, stories:, owner_backlog:) } + let(:owner_backlog) { true } let(:user) { create(:user) } let(:permissions) { [] } @@ -46,151 +47,293 @@ RSpec.describe Backlogs::BacklogMenuComponent, type: :component do .to receive(:plugin_openproject_backlogs) .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) - # Set up user with specific permissions - create(:member, - project:, - principal: user, - roles: [create(:project_role, permissions:)]) - login_as(user) + mock_permissions_for user do |mock| + mock.allow_in_project(*permissions, project:) + end end def render_component render_inline(described_class.new(backlog:, project:, current_user: user)) end - describe "permission-based items" do - context "with :manage_sprint_items permission" do - let(:permissions) { %i[view_sprints manage_sprint_items] } + it "renders a stable id on the action menu and stories/tasks item" do + render_component - it "shows Add new story item with compose icon" do - render_component + expect(page).to have_element(:button, id: /\Abacklog_#{sprint.id}_menu-button\z/) + expect(page).to have_element(:ul, id: /\Abacklog_#{sprint.id}_menu-list\z/) + expect(page).to have_element(:a, id: /\Asprint_#{sprint.id}_menu_stories_tasks\z/) + end - expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) - expect(page).to have_octicon(:compose) + context "for a product owner backlog" do + let(:owner_backlog) { true } + + describe "permission-based items" do + context "with :add_work_packages and :assign_versions permission" do + let(:permissions) { %i[view_sprints add_work_packages assign_versions] } + + it "shows Add new story item with compose icon" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + expect(page).to have_octicon(:compose) + end + end + + context "with :add_work_packages but without :assign_versions permission" do + let(:permissions) { %i[view_sprints add_work_packages] } + + it "does not show Add new story item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + end + end + + context "with :assign_versions but without :add_work_packages permission" do + let(:permissions) { %i[view_sprints assign_versions] } + + it "does not show Add new story item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + end + end + + context "without :assign_versions but with :add_work_packages and :manage_sprint_items permission" do + let(:permissions) { %i[view_sprints add_work_packages manage_sprint_items] } + + it "does not show Add new story item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + end + end + + context "with :create_sprints permission" do + let(:permissions) { %i[view_sprints create_sprints] } + + it "shows Properties item with gear icon" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) + expect(page).to have_octicon(:gear) + end + + it "shows Edit item with pencil icon" do + render_component + + expect(page).to have_css("action-menu") + expect(page).to have_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) + expect(page).to have_octicon(:pencil) + end + end + + context "without :create_sprints permission" do + let(:permissions) { [:view_sprints] } + + it "does not show Properties item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) + end + + it "does not show Edit item" do + render_component + + expect(page).to have_no_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) + end + end + + context "with :view_sprints permission" do + let(:permissions) { %i[view_sprints] } + + it "does not show Task board item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.task_board")) + end end end - context "without :manage_sprint_items permission" do + describe "permission independent items" do let(:permissions) { [:view_sprints] } - it "does not show Add new story item" do + it "shows Stories/Tasks link" do render_component - expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.stories_tasks")) + end + + it "shows no Burndown chart link" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) end end - context "with :create_sprints permission" do - let(:permissions) { %i[view_sprints create_sprints] } + describe "module-based items" do + context "when wiki module is enabled" do + let(:permissions) { [:view_sprints] } + let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs wiki]) } - it "shows Properties item with gear icon" do - render_component + it "does not show a Wiki item" do + render_component - expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) - expect(page).to have_octicon(:gear) - end - - it "shows Edit item with pencil icon" do - render_component - - expect(page).to have_css("action-menu") - expect(page).to have_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) - expect(page).to have_octicon(:pencil) - end - end - - context "without :create_sprints permission" do - let(:permissions) { [:view_sprints] } - - it "does not show Properties item" do - render_component - - expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) - end - - it "does not show Edit item" do - render_component - - expect(page).to have_no_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) - end - end - - context "with :view_sprints permission" do - let(:permissions) { %i[view_sprints] } - - it "shows Task board item" do - render_component - - expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.task_board")) + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) + end end end end - describe "always-visible items" do - let(:permissions) { [:view_sprints] } + context "for a sprint backlog" do + let(:owner_backlog) { false } - it "renders a stable id on the action menu and stories/tasks item" do - render_component + describe "permission-based items" do + context "with :add_work_packages and :assign_versions permission" do + let(:permissions) { %i[view_sprints add_work_packages assign_versions] } - expect(page).to have_element(:button, id: /\Abacklog_#{sprint.id}_menu-button\z/) - expect(page).to have_element(:ul, id: /\Abacklog_#{sprint.id}_menu-list\z/) - expect(page).to have_element(:a, id: /\Asprint_#{sprint.id}_menu_stories_tasks\z/) - end + it "shows Add new story item with compose icon" do + render_component - it "shows Stories/Tasks link" do - render_component + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + expect(page).to have_octicon(:compose) + end + end - expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.stories_tasks")) - end + context "with :add_work_packages but without :assign_versions permission" do + let(:permissions) { %i[view_sprints add_work_packages] } - it "shows Burndown chart link" do - render_component + it "does not show Add new story item" do + render_component - expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) - end + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + end + end - context "when sprint has no burndown (no dates)" do - let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: nil, effective_date: nil) } + context "with :assign_versions but without :add_work_packages permission" do + let(:permissions) { %i[view_sprints assign_versions] } - it "shows Burndown chart link as disabled" do - render_component + it "does not show Add new story item" do + render_component - burndown_item = page.find("li", text: I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) - expect(burndown_item[:class]).to include("ActionListItem--disabled") + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + end + end + + context "without :assign_versions but with :add_work_packages and :manage_sprint_items permission" do + let(:permissions) { %i[view_sprints add_work_packages manage_sprint_items] } + + it "does not show Add new story item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) + end + end + + context "with :create_sprints permission" do + let(:permissions) { %i[view_sprints create_sprints] } + + it "shows Properties item with gear icon" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) + expect(page).to have_octicon(:gear) + end + + it "shows Edit item with pencil icon" do + render_component + + expect(page).to have_css("action-menu") + expect(page).to have_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) + expect(page).to have_octicon(:pencil) + end + end + + context "without :create_sprints permission" do + let(:permissions) { [:view_sprints] } + + it "does not show Properties item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) + end + + it "does not show Edit item" do + render_component + + expect(page).to have_no_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) + end + end + + context "with :view_sprints permission" do + let(:permissions) { %i[view_sprints] } + + it "shows Task board item" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.task_board")) + end end end - context "when sprint has burndown" do - it "shows Burndown chart link as enabled" do - render_component - - burndown_item = page.find("li", text: I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) - expect(burndown_item[:class]).not_to include("ActionListItem--disabled") - end - end - end - - describe "module-based items" do - context "when wiki module is enabled" do + describe "permission independent items" do let(:permissions) { [:view_sprints] } - let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs wiki]) } - it "shows Wiki item" do + it "shows Stories/Tasks link" do render_component - expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) - expect(page).to have_octicon(:book) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.stories_tasks")) + end + + it "shows Burndown chart link" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) + end + + context "when sprint has no burndown (no dates)" do + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: nil, effective_date: nil) } + + it "shows Burndown chart link as disabled" do + render_component + + burndown_item = page.find("li", text: I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) + expect(burndown_item[:class]).to include("ActionListItem--disabled") + end + end + + context "when sprint has burndown" do + it "shows Burndown chart link as enabled" do + render_component + + burndown_item = page.find("li", text: I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) + expect(burndown_item[:class]).not_to include("ActionListItem--disabled") + end end end - context "when wiki module is disabled" do - let(:permissions) { [:view_sprints] } - let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs]) } + describe "module-based items" do + context "when wiki module is enabled" do + let(:permissions) { [:view_sprints] } + let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs wiki]) } - it "does not show Wiki item" do - render_component + it "shows Wiki item" do + render_component - expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) + expect(page).to have_octicon(:book) + end + end + + context "when wiki module is disabled" do + let(:permissions) { [:view_sprints] } + let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs]) } + + it "does not show Wiki item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) + end end end end diff --git a/modules/backlogs/spec/contracts/work_packages/create_contract_spec.rb b/modules/backlogs/spec/contracts/work_packages/create_contract_spec.rb index 74185db207b..7dcfc025236 100644 --- a/modules/backlogs/spec/contracts/work_packages/create_contract_spec.rb +++ b/modules/backlogs/spec/contracts/work_packages/create_contract_spec.rb @@ -27,44 +27,32 @@ #++ require "spec_helper" +require_relative "shared_contract_examples" RSpec.describe WorkPackages::CreateContract do - let(:work_package) { build(:work_package, author: other_user, project:) } - let(:other_user) { build_stubbed(:user) } - let(:project) { build_stubbed(:project) } + let(:work_package) do + WorkPackage.new(project: work_package_project, + subject: "Some subject", + type: work_package_type, + priority: work_package_priority, + status: work_package_status, + story_points: work_package_story_points, + sprint: work_package_sprint) do |wp| + wp.extend(OpenProject::ChangedBySystem) + + wp.change_by_system do + wp.author = work_package_author + end + end + end + let(:permissions) do %i[ view_work_packages add_work_packages + manage_sprint_items ] end - let(:changed_values) { [] } - let(:user) { build_stubbed(:user) } - - before do - mock_permissions_for(user) do |mock| - mock.allow_in_project *permissions, project: - end - - allow(work_package).to receive(:changed).and_return(changed_values) - end - - subject(:contract) { described_class.new(work_package, user) } - - describe "story points" do - before do - contract.validate - end - - context "when not changed" do - it("is valid") { expect(contract.errors.symbols_for(:story_points)).to be_empty } - end - - context "when changed" do - let(:changed_values) { ["story_points"] } - - it("is valid") { expect(contract.errors.symbols_for(:story_points)).to be_empty } - end - end + it_behaves_like "work package contract with backlogs extensions" end diff --git a/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb b/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb new file mode 100644 index 00000000000..7634cfbaa48 --- /dev/null +++ b/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "contracts/shared/model_contract_shared_context" + +RSpec.shared_examples "work package contract with backlogs extensions" do + include_context "ModelContract shared context" + let(:work_package_type) { build_stubbed(:type) } + let(:work_package_status) { build_stubbed(:status) } + let(:work_package_priority) { build_stubbed(:priority) } + let(:work_package_author) { build_stubbed(:user) } + let(:work_package_story_points) { 5 } + let(:work_package_sprint) { build_stubbed(:agile_sprint) } + let(:work_package_position) { 5 } + let(:shared_sprints) { [work_package_sprint] } + let(:backlogs_enabled) { true } + let(:work_package_project) do + build_stubbed(:project, types: [work_package_type]) do |project| + allow(project).to receive(:backlogs_enabled?).and_return(backlogs_enabled) + end + end + let(:user) do + build_stubbed(:user) do |user| + mock_permissions_for(user) do |mock| + mock.allow_in_project *effective_permissions, project: work_package_project + end + end + end + + subject(:contract) { described_class.new(work_package, user) } + + let(:effective_permissions) { permissions } + + before do + shared_sprints_scope = instance_double(ActiveRecord::Relation) + + allow(Agile::Sprint) + .to receive(:for_project) + .with(work_package.project) + .and_return(shared_sprints_scope) + + allow(shared_sprints_scope) + .to receive(:exists?) do |id:| + shared_sprints.map(&:id).include?(id.to_i) + end + end + + describe "validations" do + context "when all attributes are valid" do + it_behaves_like "contract is valid" + end + + context "when story points are empty" do + let(:work_package_story_points) { nil } + + it_behaves_like "contract is valid" + end + + context "when story points are 0" do + let(:work_package_story_points) { 0 } + + it_behaves_like "contract is valid" + end + + context "when story points are negative" do + let(:work_package_story_points) { -1 } + + it_behaves_like "contract is invalid", story_points: :greater_than_or_equal_to + end + + context "when story points are larger than 10000" do + let(:work_package_story_points) { 10001 } + + it_behaves_like "contract is invalid", story_points: :less_than + end + + context "when story points are floats" do + let(:work_package_story_points) { 1.1 } + + it_behaves_like "contract is invalid", story_points: :not_an_integer + end + + context "when changing story points with backlogs being disabled" do + let(:backlogs_enabled) { false } + + it_behaves_like "contract is invalid", story_points: :error_readonly + end + + context "when sprint is set to nil" do + let(:work_package_sprint) { nil } + + it_behaves_like "contract is valid" + end + + context "when sprint is set to a sprint not shared with the wp's project" do + let(:shared_sprints) { [] } + + it_behaves_like "contract is invalid", sprint: :not_shared_with_project + end + + context "when sprint is set while the user lacks the :manage_sprint_items permission" do + let(:effective_permissions) { permissions - [:manage_sprint_items] } + + it_behaves_like "contract is invalid", sprint_id: :error_readonly + end + + context "when position is written by the user" do + before do + work_package.position = work_package_position + end + + it_behaves_like "contract is invalid", position: :error_readonly + end + end + + describe "writable_attributes" do + it "includes sprint and story_points", :aggregate_failures do + expect(contract.writable_attributes).to include("story_points", "sprint") + expect(contract.writable_attributes).not_to include("position") + end + + context "when the user lacks the :manage_sprint_items permission" do + let(:effective_permissions) { permissions - [:manage_sprint_items] } + + it "includes story_points but lacks sprint", :aggregate_failures do + expect(contract.writable_attributes).to include("story_points") + expect(contract.writable_attributes).not_to include("sprint", "position") + end + end + + context "when backlogs is deactivated" do + let(:backlogs_enabled) { false } + # Removing the permission here as this is what will happen when deactivating backlogs. + # Otherwise, the production would need to have a superfluous check. + let(:effective_permissions) { permissions - [:manage_sprint_items] } + + it "includes non of the backlogs attributes", :aggregate_failures do + expect(contract.writable_attributes).not_to include("story_points", "sprint", "position") + end + end + end +end diff --git a/modules/backlogs/spec/contracts/work_packages/update_contract_spec.rb b/modules/backlogs/spec/contracts/work_packages/update_contract_spec.rb index 11e1227ccca..1f3885b6294 100644 --- a/modules/backlogs/spec/contracts/work_packages/update_contract_spec.rb +++ b/modules/backlogs/spec/contracts/work_packages/update_contract_spec.rb @@ -27,46 +27,91 @@ #++ require "spec_helper" +require_relative "shared_contract_examples" RSpec.describe WorkPackages::UpdateContract do let(:work_package) do - create(:work_package, - done_ratio: 50, - estimated_hours: 6.0, - project:) + build_stubbed(:work_package, + project: work_package_project, + subject: "Some subject", + type: work_package_type, + priority: work_package_priority, + status: work_package_status) do |wp| + wp.story_points = work_package_story_points + wp.sprint = work_package_sprint + wp.extend(OpenProject::ChangedBySystem) + end end - let(:member) { create(:user, member_with_roles: { project => role }) } - let(:project) { create(:project) } - let(:current_user) { member } + let(:permissions) do %i[ view_work_packages - view_work_package_watchers edit_work_packages - add_work_package_watchers - delete_work_package_watchers - manage_work_package_relations - add_work_package_comments + manage_sprint_items ] end - let(:role) { create(:project_role, permissions:) } - let(:changed_values) { [] } - - subject(:contract) { described_class.new(work_package, current_user) } before do - allow(work_package).to receive(:changed).and_return(changed_values) + visible_scope = instance_double(ActiveRecord::Relation) + + allow(WorkPackage) + .to receive(:visible) + .with(user) + .and_return(visible_scope) + allow(visible_scope) + .to receive(:exists?) + .with(work_package.id) + .and_return(true) end - describe "story points" do - context "has not changed" do - it("is valid") { expect(contract.errors.empty?).to be true } + it_behaves_like "work package contract with backlogs extensions" do + describe "validations" do + context "when setting sprint and lock_version " \ + "and only having the manage_sprint_items permission but lacking edit_work_packages" do + let(:permissions) { %i[view_work_packages manage_sprint_items] } + + before do + # Reverting the change done in the setup + work_package.restore_attributes([:story_points]) + end + + it_behaves_like "contract is valid" + end + + context "when setting the sprint and another property " \ + "and only having the manage_sprint_items permission but lacking edit_work_packages" do + let(:permissions) { %i[view_work_packages manage_sprint_items] } + + before do + work_package.subject = "Some other subject" + end + + it_behaves_like "contract is invalid", + subject: :error_readonly, + story_points: :error_readonly + end end - context "has changed" do - let(:changed_values) { ["story_points"] } + describe "writable_attributes" do + context "when the user has only :manage_sprint_items permission but lacks :edit_work_packages" do + let(:permissions) { %i[view_work_packages manage_sprint_items] } - it("is valid") { expect(contract.errors.empty?).to be true } + it "includes sprints and lock_version", :aggregate_failures do + expect(contract.writable_attributes).to include("sprint", "lock_version") + expect(contract.writable_attributes).not_to include("story_points", "position") + end + end + end + + describe ".update_allowed?" do + context "with the user having manage_sprint_items" do + let(:permissions) { [:manage_sprint_items] } + + it "is allowed" do + expect(described_class) + .to be_update_allowed(user:, work_package:) + end + end end end end diff --git a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb index 5800e48d2ca..72529a129f7 100644 --- a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb +++ b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb @@ -40,7 +40,7 @@ RSpec.describe "Backlogs context menu", :js do member_with_permissions: { project => %i[add_work_packages view_sprints view_work_packages - manage_sprint_items] }) + assign_versions] }) end shared_let(:sprint) do create(:version, @@ -136,7 +136,7 @@ RSpec.describe "Backlogs context menu", :js do context "when the user does not have manage_sprint_items permission" do before do - RolePermission.where(permission: "manage_sprint_items").delete_all + RolePermission.where(permission: "assign_versions").delete_all end it 'does not display the "New story" menu entry' do diff --git a/modules/backlogs/spec/features/backlogs/create_story_spec.rb b/modules/backlogs/spec/features/backlogs/create_story_spec.rb index 37e3d888493..598ace64f58 100644 --- a/modules/backlogs/spec/features/backlogs/create_story_spec.rb +++ b/modules/backlogs/spec/features/backlogs/create_story_spec.rb @@ -56,7 +56,7 @@ RSpec.describe "Backlogs", :js do member_with_permissions: { project => %i(add_work_packages view_sprints view_work_packages - manage_sprint_items) }) + assign_versions) }) end let(:project) { create(:project) } diff --git a/modules/backlogs/spec/features/impediments_spec.rb b/modules/backlogs/spec/features/impediments_spec.rb index 725bacfd8bb..e54a0d002cf 100644 --- a/modules/backlogs/spec/features/impediments_spec.rb +++ b/modules/backlogs/spec/features/impediments_spec.rb @@ -59,7 +59,6 @@ RSpec.describe "Impediments on taskboard", :js, view_work_packages edit_work_packages manage_subtasks - manage_sprint_items work_package_assigned)) end let!(:current_user) do diff --git a/modules/backlogs/spec/features/stories_in_backlog_spec.rb b/modules/backlogs/spec/features/stories_in_backlog_spec.rb index 37dc5efc407..a66da2bb661 100644 --- a/modules/backlogs/spec/features/stories_in_backlog_spec.rb +++ b/modules/backlogs/spec/features/stories_in_backlog_spec.rb @@ -53,7 +53,7 @@ RSpec.describe "Stories in backlog", :js, :settings_reset do let(:role) do create(:project_role, permissions: %i(view_sprints - manage_sprint_items + assign_versions add_work_packages view_work_packages edit_work_packages diff --git a/modules/backlogs/spec/features/tasks_on_taskboard_spec.rb b/modules/backlogs/spec/features/tasks_on_taskboard_spec.rb index f9539baf4c2..0b2c63f38aa 100644 --- a/modules/backlogs/spec/features/tasks_on_taskboard_spec.rb +++ b/modules/backlogs/spec/features/tasks_on_taskboard_spec.rb @@ -56,7 +56,7 @@ RSpec.describe "Tasks on taskboard", :js, view_work_packages edit_work_packages manage_subtasks - manage_sprint_items + assign_versions work_package_assigned)) end let!(:current_user) do diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb index c7ff81744af..e94c967dd28 100644 --- a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb @@ -127,6 +127,22 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering" do it_behaves_like "has no link" end end + + describe "update links" do + context "when user lacks edit permission but has manage_sprint_items" do + let(:permissions) { super() + [:manage_sprint_items] } + + it_behaves_like "has an untitled link" do + let(:link) { "update" } + let(:href) { api_v3_paths.work_package_form(work_package.id) } + end + + it_behaves_like "has an untitled link" do + let(:link) { "updateImmediately" } + let(:href) { api_v3_paths.work_package(work_package.id) } + end + end + end end describe "embedded" do diff --git a/spec/models/work_package/sprint_journaling_spec.rb b/modules/backlogs/spec/models/work_packages/sprint_journaling_spec.rb similarity index 72% rename from spec/models/work_package/sprint_journaling_spec.rb rename to modules/backlogs/spec/models/work_packages/sprint_journaling_spec.rb index f889abd12bc..260a3d97520 100644 --- a/spec/models/work_package/sprint_journaling_spec.rb +++ b/modules/backlogs/spec/models/work_packages/sprint_journaling_spec.rb @@ -33,66 +33,47 @@ require "spec_helper" RSpec.describe "WorkPackage sprint association journaling", # rubocop:disable RSpec/DescribeClass with_settings: { journal_aggregation_time_minutes: 0 } do shared_let(:project) { create(:project) } - shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_work_packages edit_work_packages] }) } shared_let(:sprint1) { create(:agile_sprint, name: "Sprint 1", project:) } shared_let(:sprint2) { create(:agile_sprint, name: "Sprint 2", project:) } - shared_let(:work_package) { create(:work_package, project:) } - - before do - login_as(user) + shared_let(:work_package_with_sprint) do + create(:work_package, :created_in_past, created_at: 1.day.ago, project:, sprint: sprint1) end + shared_let(:work_package_without_sprint) { create(:work_package, :created_in_past, created_at: 1.day.ago, project:) } it "creates a journal entry when sprint is assigned" do expect do - WorkPackages::UpdateService - .new(user:, model: work_package) - .call(sprint: sprint1) + work_package_without_sprint.update!(sprint: sprint1) end.to change(Journal::WorkPackageJournal, :count).by(1) - last_journal = work_package.journals.last + last_journal = work_package_without_sprint.journals.last expect(last_journal.details).to have_key("sprint_id") expect(last_journal.details["sprint_id"]).to eq([nil, sprint1.id]) end it "creates a journal entry when sprint is changed" do - work_package.update!(sprint: sprint1) - work_package.reload - expect do - WorkPackages::UpdateService - .new(user:, model: work_package) - .call(sprint: sprint2) + work_package_with_sprint.update!(sprint: sprint2) end.to change(Journal::WorkPackageJournal, :count).by(1) - last_journal = work_package.journals.last + last_journal = work_package_with_sprint.journals.last expect(last_journal.details).to have_key("sprint_id") expect(last_journal.details["sprint_id"]).to eq([sprint1.id, sprint2.id]) end it "creates a journal entry when sprint is removed" do - work_package.update!(sprint: sprint1) - work_package.reload - expect do - WorkPackages::UpdateService - .new(user:, model: work_package) - .call(sprint: nil) + work_package_with_sprint.update!(sprint: nil) end.to change(Journal::WorkPackageJournal, :count).by(1) - last_journal = work_package.journals.last + last_journal = work_package_with_sprint.journals.last expect(last_journal.details).to have_key("sprint_id") expect(last_journal.details["sprint_id"]).to eq([sprint1.id, nil]) end it "formats the sprint change in the journal" do - work_package.update!(sprint: sprint1) - work_package.reload + work_package_with_sprint.update!(sprint: sprint2) - WorkPackages::UpdateService - .new(user:, model: work_package) - .call(sprint: sprint2) - - last_journal = work_package.journals.last + last_journal = work_package_with_sprint.journals.last formatted = last_journal.render_detail("sprint_id", no_html: true) expect(formatted).to include("Sprint 1") diff --git a/modules/backlogs/spec/requests/api/v3/work_packages/create_resource_spec.rb b/modules/backlogs/spec/requests/api/v3/work_packages/create_resource_spec.rb new file mode 100644 index 00000000000..0618bb113be --- /dev/null +++ b/modules/backlogs/spec/requests/api/v3/work_packages/create_resource_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Work package resource", + content_type: :json do + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:type) { project.types.first } + shared_let(:status) { create(:status, is_default: true) } + shared_let(:priority) { create(:priority, is_default: true) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:outside_sprint) { create(:agile_sprint, project: create(:project)) } + + let(:role) { create(:project_role, permissions:) } + let(:permissions) { %i[add_work_packages view_work_packages manage_sprint_items] } + + current_user do + create(:user, member_with_roles: { project => role }) + end + + describe "POST /api/v3/work_packages" do + let(:path) { api_v3_paths.work_packages } + let(:parameters) do + { + subject: "new work packages", + storyPoints: 5, + _links: { + type: { + href: api_v3_paths.type(type.id) + }, + project: { + href: api_v3_paths.project(project.id) + }, + sprint: { + href: api_v3_paths.sprint(sprint.id) + } + } + } + end + + before do + post path, parameters.to_json + end + + it_behaves_like "successful response", 201, "WorkPackage" + + it "creates a work package" do + expect(WorkPackage.count).to eq(1) + end + + it "applies the given parameters" do + expect(WorkPackage.first.attributes.slice("sprint_id", "story_points", "position")) + .to eq( + { + "sprint_id" => sprint.id, + "story_points" => 5, + "position" => 1 + } + ) + end + + context "when the user does not have permission to manage sprint items" do + let(:permissions) { %i[add_work_packages view_work_packages] } + + it_behaves_like "read-only violation", "sprint", WorkPackage + end + + context "when attempting to create the work package on a non valid sprint" do + let(:parameters) do + { + subject: "new work packages", + storyPoints: 5, + _links: { + type: { + href: api_v3_paths.type(type.id) + }, + project: { + href: api_v3_paths.project(project.id) + }, + sprint: { + href: api_v3_paths.sprint(outside_sprint.id) + } + } + } + end + + it_behaves_like "constraint violation" do + let(:message) { "Sprint is not shared with the project the work package is in." } + end + end + end +end diff --git a/modules/backlogs/spec/requests/api/v3/work_packages/update_resource_spec.rb b/modules/backlogs/spec/requests/api/v3/work_packages/update_resource_spec.rb new file mode 100644 index 00000000000..e63c108ada6 --- /dev/null +++ b/modules/backlogs/spec/requests/api/v3/work_packages/update_resource_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Work package resource", + content_type: :json do + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:type) { project.types.first } + shared_let(:status) { create(:status, is_default: true) } + shared_let(:priority) { create(:priority, is_default: true) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:outside_sprint) { create(:agile_sprint, project: create(:project)) } + shared_let(:work_package) { create(:work_package, project:, type:, status:, priority:) } + + let(:role) { create(:project_role, permissions:) } + let(:permissions) { %i[edit_work_packages view_work_packages manage_sprint_items] } + + current_user do + create(:user, member_with_roles: { project => role }) + end + + describe "PATCH /api/v3/work_packages/:id" do + let(:path) { api_v3_paths.work_package(work_package.id) } + let(:parameters) do + { + storyPoints: 5, + lockVersion: work_package.lock_version, + _links: { + sprint: { + href: api_v3_paths.sprint(sprint.id) + } + } + } + end + + before do + patch path, parameters.to_json + end + + it_behaves_like "successful response", 200, "WorkPackage" + + it "applies the given parameters" do + expect(WorkPackage.first.attributes.slice("sprint_id", "story_points", "position")) + .to eq( + { + "sprint_id" => sprint.id, + "story_points" => 5, + "position" => 1 + } + ) + end + + context "when the user does not have permission to manage sprint items" do + let(:permissions) { %i[edit_work_packages view_work_packages] } + + it_behaves_like "read-only violation", "sprint", WorkPackage + end + + context "when the user has only the permission to manage sprint items and changes only the sprint" do + let(:permissions) { %i[view_work_packages manage_sprint_items] } + + let(:parameters) do + { + lockVersion: work_package.lock_version, + _links: { + sprint: { + href: api_v3_paths.sprint(sprint.id) + } + } + } + end + + it_behaves_like "successful response", 200, "WorkPackage" + + it "applies the given parameters" do + expect(WorkPackage.first.sprint) + .to eq(sprint) + end + end + + context "when the user has only the permission to manage sprint items and changes more than the sprint" do + let(:permissions) { %i[view_work_packages manage_sprint_items] } + + let(:parameters) do + { + lockVersion: work_package.lock_version, + subject: "abc", + _links: { + sprint: { + href: api_v3_paths.sprint(sprint.id) + } + } + } + end + + it_behaves_like "read-only violation", "subject", WorkPackage + end + + context "when attempting to assign the work package to a non valid sprint" do + let(:parameters) do + { + storyPoints: 5, + lockVersion: work_package.lock_version, + _links: { + sprint: { + href: api_v3_paths.sprint(outside_sprint.id) + } + } + } + end + + it_behaves_like "constraint violation" do + let(:message) { "Sprint is not shared with the project the work package is in." } + end + end + end +end diff --git a/modules/backlogs/spec/services/impediments/create_services_spec.rb b/modules/backlogs/spec/services/impediments/create_services_spec.rb index da7317501a4..21012fa323a 100644 --- a/modules/backlogs/spec/services/impediments/create_services_spec.rb +++ b/modules/backlogs/spec/services/impediments/create_services_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Impediments::CreateService do let(:impediment_subject) { "Impediment A" } let(:user) { create(:user) } - let(:role) { create(:project_role, permissions: %i(add_work_packages manage_sprint_items work_package_assigned)) } + let(:role) { create(:project_role, permissions: %i(add_work_packages assign_versions work_package_assigned)) } let(:type_feature) { create(:type_feature) } let(:type_task) { create(:type_task) } let(:priority) { create(:priority, is_default: true) } diff --git a/modules/backlogs/spec/services/stories/create_service_spec.rb b/modules/backlogs/spec/services/stories/create_service_spec.rb index 87483c4a055..0ee06ac9d8f 100644 --- a/modules/backlogs/spec/services/stories/create_service_spec.rb +++ b/modules/backlogs/spec/services/stories/create_service_spec.rb @@ -35,7 +35,7 @@ RSpec.describe Stories::CreateService, type: :model do let(:type_feature) { create(:type_feature) } let(:user) do - create(:user, member_with_permissions: { project => %i(add_work_packages manage_subtasks manage_sprint_items) }) + create(:user, member_with_permissions: { project => %i(add_work_packages manage_subtasks assign_versions) }) end let(:instance) do diff --git a/spec/contracts/work_packages/update_contract_spec.rb b/spec/contracts/work_packages/update_contract_spec.rb index 8d888aca538..4c2dd968ffc 100644 --- a/spec/contracts/work_packages/update_contract_spec.rb +++ b/spec/contracts/work_packages/update_contract_spec.rb @@ -392,18 +392,6 @@ RSpec.describe WorkPackages::UpdateContract do .not_to include("subject", "start_date", "description") end end - - context "for a user having only the manage_sprint_items permission" do - let(:permissions) { %i[manage_sprint_items] } - - it "includes version_id only" do - expect(subject) - .to include("version_id", "version", "lock_version_id", "lock_version") - - expect(subject) - .not_to include("subject", "start_date", "description") - end - end end describe "#assignable_assignees" do @@ -419,4 +407,70 @@ RSpec.describe WorkPackages::UpdateContract do .to contain_exactly(persisted_possible_assignee) end end + + describe ".update_allowed?" do + %i[edit_work_packages + assign_versions + move_work_packages + change_work_package_status + manage_subtasks].each do |permission| + context "with the user having #{permission}" do + let(:permissions) { [permission] } + + it "is allowed" do + expect(described_class) + .to be_update_allowed(user:, work_package:) + end + end + end + + context "with the user having view_work_packages" do + let(:permissions) { %i[view_work_packages] } + + it "is not allowed" do + expect(described_class) + .not_to be_update_allowed(user:, work_package:) + end + end + end + + describe ".update_parent_allowed?" do + context "with the user having manage_subtasks" do + let(:permissions) { [:manage_subtasks] } + + it "is allowed" do + expect(described_class) + .to be_update_parent_allowed(user:, work_package:) + end + end + + context "with the user having the other edit permissions" do + let(:permissions) { %i[edit_work_packages assign_versions move_work_packages change_work_package_status] } + + it "is not allowed" do + expect(described_class) + .not_to be_update_parent_allowed(user:, work_package:) + end + end + end + + describe ".add_comments_allowed?" do + context "with the user having manage_subtasks" do + let(:permissions) { [:add_work_package_comments] } + + it "is allowed" do + expect(described_class) + .to be_add_comments_allowed(user:, work_package:) + end + end + + context "with the user having the other edit permissions" do + let(:permissions) { %i[edit_work_packages assign_versions move_work_packages change_work_package_status] } + + it "is not allowed" do + expect(described_class) + .not_to be_add_comments_allowed(user:, work_package:) + end + end + end end diff --git a/spec/features/work_packages/edit_on_assign_version_permission_spec.rb b/spec/features/work_packages/edit_on_assign_version_permission_spec.rb index ab0d2e05907..9b4219e288d 100644 --- a/spec/features/work_packages/edit_on_assign_version_permission_spec.rb +++ b/spec/features/work_packages/edit_on_assign_version_permission_spec.rb @@ -10,7 +10,7 @@ RSpec.describe "edit work package", :js do lastname: "Guy", member_with_permissions: { project => permissions }) end - let(:permissions) { %i[view_work_packages manage_sprint_items assign_versions] } + let(:permissions) { %i[view_work_packages assign_versions] } let(:cf_all) do create(:work_package_custom_field, is_for_all: true, field_format: "text") @@ -53,20 +53,6 @@ RSpec.describe "edit work package", :js do end end - context "as a user having only the manage_sprint_items permission" do - let(:permissions) { %i[view_work_packages manage_sprint_items] } - - it "can only change the version" do - wp_page.fill_in_attributes version: version.name - - wp_page.expect_toast(message: "Successful update") - wp_page.expect_attributes version: version.name - - subject_field = wp_page.work_package_field("subject") - subject_field.expect_read_only - end - end - context "as a user having only the edit_work_packages permission" do let(:permissions) { %i[view_work_packages edit_work_packages] } diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index e8abbfe723d..d62854f2785 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -490,20 +490,6 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter do end end - context "when user lacks edit permission but has manage_sprint_items" do - let(:permissions) { all_permissions - [:edit_work_packages] + [:manage_sprint_items] } - - it_behaves_like "has an untitled link" do - let(:link) { "update" } - let(:href) { api_v3_paths.work_package_form(work_package.id) } - end - - it_behaves_like "has an untitled link" do - let(:link) { "updateImmediately" } - let(:href) { api_v3_paths.work_package(work_package.id) } - end - end - context "when user lacks edit permission but has change_work_package_status" do let(:permissions) { all_permissions - [:edit_work_packages] + [:change_work_package_status] } @@ -1179,14 +1165,14 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter do end end - describe "move" do - it_behaves_like "has a titled action link" do - let(:link) { "move" } - let(:href) { "/work_packages/#{work_package.id}/move/new" } - let(:permission) { :move_work_packages } - let(:title) { "Move work package '#{work_package.subject}'" } - end + describe "move" do + it_behaves_like "has a titled action link" do + let(:link) { "move" } + let(:href) { "/work_packages/#{work_package.id}/move/new" } + let(:permission) { :move_work_packages } + let(:title) { "Move work package '#{work_package.subject}'" } end + end describe "copy" do it_behaves_like "has a titled action link" do From 43105854469c820e64cf7f471f51f6c382f1df73 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:35:30 +0100 Subject: [PATCH 089/435] Use render_tab_header_nav helper --- .../my/working_times_header_component.rb | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/app/components/my/working_times_header_component.rb b/app/components/my/working_times_header_component.rb index 76f1ba27b22..8fa797e25c6 100644 --- a/app/components/my/working_times_header_component.rb +++ b/app/components/my/working_times_header_component.rb @@ -30,25 +30,31 @@ module My class WorkingTimesHeaderComponent < ApplicationComponent - def call # rubocop:disable Metrics/AbcSize + def call render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { t(:label_schedule_and_availability) } header.with_breadcrumbs( [{ href: my_account_path, text: t(:label_my_account) }, t(:label_schedule_and_availability)] ) - header.with_tab_nav(label: "label") do |nav| - nav.with_tab(selected: params[:action] == "working_hours", - href: my_working_hours_path) do |tab| - tab.with_text { t(:label_working_hours) } - end - nav.with_tab(selected: params[:action] == "non_working_times", - href: my_non_working_times_path(year: Date.current.year)) do |tab| - tab.with_text { t(:label_non_working_days) } - end - end + helpers.render_tab_header_nav(header, tabs) end end + + def tabs + [ + { + name: "working_hours", + path: my_working_hours_path, + label: t(:label_working_hours) + }, + { + name: "non_working_times", + path: my_non_working_times_path(year: Date.current.year), + label: t(:label_non_working_days) + } + ] + end end end From cf9bf938ea13eeea2d5f33a9c915b7ce3739dd6e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 13:51:12 +0100 Subject: [PATCH 090/435] Fix heading for recreate script --- bin/recreate-database | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bin/recreate-database b/bin/recreate-database index 7d57c1f905d..f5af48c7ae2 100755 --- a/bin/recreate-database +++ b/bin/recreate-database @@ -1,7 +1,7 @@ #!/usr/bin/env bash # -# Deletes bundled javascript assets and rebuilds them. -# Useful for when your frontend doesn't work (jQuery not defined etc.) for seemingly no reason at all. +# Correctly rebuilds the database from scratch by dropping it, deleting the structure.sql, creating it again, +# running all migrations and seeding it. die() { yell "$*"; exit 1; } try() { eval "$@" || die "\n\nFailed to run '$*'"; } @@ -22,5 +22,3 @@ echo "Seeding database" try "bundle exec rake db:seed" echo "✔ Done." - - From e2cc1c35123b7a3a02faed0ffa585c203fd07fc3 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 14:06:28 +0100 Subject: [PATCH 091/435] Add TODO about possibly adding a future-only validation --- app/contracts/user_working_hours/base_contract.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/contracts/user_working_hours/base_contract.rb b/app/contracts/user_working_hours/base_contract.rb index a67e79e72bb..1dc45ea915d 100644 --- a/app/contracts/user_working_hours/base_contract.rb +++ b/app/contracts/user_working_hours/base_contract.rb @@ -35,6 +35,9 @@ class UserWorkingHours::BaseContract < ModelContract attribute :availability_factor validate :validate_manage_permission + # TODO: Possibly add a validation that we only add working hours for future dates. We will start without it for + # now, but let's consider adding it in the future to prevent users from accidentally changing their historic + # data. def self.model = ::UserWorkingHours From 977943b7df75373cf7efacbd57c806ca0351b836 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 14:09:49 +0100 Subject: [PATCH 092/435] remove unneeded permission route mappings --- config/initializers/permissions.rb | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index e12124b9789..75233ee0080 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -288,17 +288,11 @@ Rails.application.reloader.to_prepare do require: :loggedin map.permission :manage_own_working_times, - { - "users/working_hours": %i[index create update destroy], - "users/non_working_days": %i[index create destroy] - }, + {}, permissible_on: :global map.permission :manage_working_times, - { - "users/working_hours": %i[index create update destroy], - "users/non_working_days": %i[index create destroy] - }, + {}, permissible_on: :global end From 6460825b163edb1abb0c6b056320668ef9392bdd Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 14:15:22 +0100 Subject: [PATCH 093/435] Update description for history --- config/locales/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index cafa2825875..c5d7b3c145f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1124,7 +1124,7 @@ en: blank_description: "Create a future schedule to plan changes ahead of time" history: title: "Schedule history" - description: "View your past work schedule changes and restore previous working times" + description: "View your past work schedules." blank_title: "No schedule history yet" blank_description: "Past schedule changes will appear here" destroy: From 373eb274f21d93a35d8533dc1393ffe3029e5625 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 14:44:46 +0100 Subject: [PATCH 094/435] Do validation on the hours field, not minutes for proper error messages --- app/models/user_working_hours.rb | 4 ++-- spec/models/user_working_hours_spec.rb | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/user_working_hours.rb b/app/models/user_working_hours.rb index 1a8a11681d1..046b7d05399 100644 --- a/app/models/user_working_hours.rb +++ b/app/models/user_working_hours.rb @@ -36,9 +36,9 @@ class UserWorkingHours < ApplicationRecord belongs_to :user, inverse_of: :working_hours validates :valid_from, presence: true, uniqueness: { scope: :user_id } - validates :monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday, + validates :monday_hours, :tuesday_hours, :wednesday_hours, :thursday_hours, :friday_hours, :saturday_hours, :sunday_hours, presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 24 * 60 } + numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 } validates :availability_factor, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb index 75cc783ce46..f883e7e50b8 100644 --- a/spec/models/user_working_hours_spec.rb +++ b/spec/models/user_working_hours_spec.rb @@ -39,12 +39,12 @@ RSpec.describe UserWorkingHours do it { is_expected.to validate_presence_of(:valid_from) } %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| - it { is_expected.to validate_presence_of(day) } + it { is_expected.to validate_presence_of(:"#{day}_hours") } it do - expect(subject).to validate_numericality_of(day).only_integer - .is_greater_than_or_equal_to(0) - .is_less_than_or_equal_to(24 * 60) + expect(subject).to validate_numericality_of(:"#{day}_hours") + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(24) end end From 1d9e0b7c9109181be11b6bf668171075446f76b0 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 14:58:17 +0100 Subject: [PATCH 095/435] add api endpoint to edit non working days. fix api docs --- .../schemas/user_non_working_time_model.yml | 25 ++- docs/api/apiv3/openapi-spec.yml | 2 +- .../paths/user_non_working_times_date.yml | 189 ++++++++++++++++-- .../non_working_times_by_user_api.rb | 2 + .../non_working_times_by_user_api_spec.rb | 59 ++++++ 5 files changed, 253 insertions(+), 24 deletions(-) diff --git a/docs/api/apiv3/components/schemas/user_non_working_time_model.yml b/docs/api/apiv3/components/schemas/user_non_working_time_model.yml index 8860c8c7bf7..30d83ff1aa1 100644 --- a/docs/api/apiv3/components/schemas/user_non_working_time_model.yml +++ b/docs/api/apiv3/components/schemas/user_non_working_time_model.yml @@ -4,7 +4,8 @@ type: object required: - _type - id - - date + - startDate + - endDate properties: _type: type: string @@ -13,14 +14,19 @@ properties: id: type: integer description: |- - The unique identifier of the non-working day record. + The unique identifier of the non-working time record. minimum: 1 - date: + startDate: type: string format: date description: |- - The date of the non-working day in ISO 8601 format (YYYY-MM-DD). - Cannot coincide with a system-wide non-working day. + The first date of the non-working time range in ISO 8601 format (YYYY-MM-DD). + endDate: + type: string + format: date + description: |- + The last date of the non-working time range in ISO 8601 format (YYYY-MM-DD). + Must be greater than or equal to `startDate`. _links: type: object required: @@ -31,22 +37,23 @@ properties: allOf: - $ref: './link.yml' - description: |- - This non-working day record. + This non-working time record. **Resource**: UserNonWorkingTime user: allOf: - $ref: './link.yml' - description: |- - The user this non-working day belongs to. + The user this non-working time belongs to. **Resource**: User example: _type: UserNonWorkingTime id: 7 - date: '2025-06-16' + startDate: '2025-06-16' + endDate: '2025-06-20' _links: self: - href: /api/v3/users/42/non_working_times/2025-06-16 + href: /api/v3/users/42/non_working_times/7 user: href: /api/v3/users/42 title: Jane Doe diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index a01381b8590..590e3f7e915 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -484,7 +484,7 @@ paths: "$ref": "./paths/user_lock.yml" "/api/v3/users/{id}/non_working_times": "$ref": "./paths/user_non_working_times.yml" - "/api/v3/users/{id}/non_working_times/{date}": + "/api/v3/users/{id}/non_working_times/{non_working_time_id}": "$ref": "./paths/user_non_working_times_date.yml" "/api/v3/users/{id}/working_hours": "$ref": "./paths/user_working_hours.yml" diff --git a/docs/api/apiv3/paths/user_non_working_times_date.yml b/docs/api/apiv3/paths/user_non_working_times_date.yml index 7ed004938bd..d38922b8fba 100644 --- a/docs/api/apiv3/paths/user_non_working_times_date.yml +++ b/docs/api/apiv3/paths/user_non_working_times_date.yml @@ -1,17 +1,17 @@ -# /api/v3/users/{id}/non_working_times/{date} +# /api/v3/users/{id}/non_working_times/{non_working_time_id} --- -delete: - summary: Delete a personal non-working day - operationId: delete_user_non_working_time +get: + summary: View a personal non-working time record + operationId: view_user_non_working_time tags: - User Working Times description: |- - Removes the personal non-working day for the given user and date. + Returns a single personal non-working time record for the given user. **Required permissions:** - - Administrators can delete non-working days for any user. - - Users with the global `manage_own_working_times` permission can delete their own records. - - Users with the global `manage_working_times` permission can delete non-working days for any user. + - Administrators can view non-working time records for any user. + - Users with the global `manage_own_working_times` permission can view their own records. + - Users with the global `manage_working_times` permission can view non-working time records for any user. Use `me` as the `id` to reference the current user. parameters: @@ -23,15 +23,176 @@ delete: schema: type: string example: 42 - - name: date + - name: non_working_time_id in: path required: true description: |- - The date of the personal non-working day to delete, in ISO 8601 format (YYYY-MM-DD). + The id of the personal non-working time record. + schema: + type: integer + example: 7 + responses: + "200": + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_non_working_time_model.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the user or non-working time record does not exist, + or if the requesting user does not have permission to view it. + +patch: + summary: Update a personal non-working time record + operationId: update_user_non_working_time + tags: + - User Working Times + description: |- + Updates the given personal non-working time record. + + **Required permissions:** + - Administrators can update non-working time records for any user. + - Users with the global `manage_own_working_times` permission can update their own records. + - Users with the global `manage_working_times` permission can update non-working time records for any user. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. schema: type: string - format: date - example: "2025-06-16" + example: 42 + - name: non_working_time_id + in: path + required: true + description: |- + The id of the personal non-working time record. + schema: + type: integer + example: 7 + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/user_non_working_time_model.yml" + example: + startDate: "2025-06-23" + endDate: "2025-06-27" + responses: + "200": + description: OK + content: + application/hal+json: + schema: + $ref: "../components/schemas/user_non_working_time_model.yml" + "400": + $ref: "../components/responses/invalid_request_body.yml" + "401": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:Unauthenticated + message: You need to be authenticated to access this resource. + description: Returned if the client is not authenticated. + "403": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** `manage_working_times` globally (for other users) or + `manage_own_working_times` globally (for own records). + "404": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the user or non-working time record does not exist, + or if the requesting user does not have permission to view it. + "406": + $ref: "../components/responses/missing_content_type.yml" + "415": + $ref: "../components/responses/unsupported_media_type.yml" + "422": + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:PropertyConstraintViolation + message: Validation failed. + description: |- + Returned if the request body contains invalid parameters, or if the date range + overlaps with an existing non-working time record for the user. + +delete: + summary: Delete a personal non-working time record + operationId: delete_user_non_working_time + tags: + - User Working Times + description: |- + Removes the personal non-working time record for the given user. + + **Required permissions:** + - Administrators can delete non-working time records for any user. + - Users with the global `manage_own_working_times` permission can delete their own records. + - Users with the global `manage_working_times` permission can delete non-working time records for any user. + + Use `me` as the `id` to reference the current user. + parameters: + - name: id + in: path + required: true + description: |- + User id. Use `me` to reference the current user. + schema: + type: string + example: 42 + - name: non_working_time_id + in: path + required: true + description: |- + The id of the personal non-working time record. + schema: + type: integer + example: 7 responses: "204": description: |- @@ -71,5 +232,5 @@ delete: errorIdentifier: urn:openproject-org:api:v3:errors:NotFound message: The requested resource could not be found. description: |- - Returned if the user does not exist, is not visible to the requesting user, - or no personal non-working day exists for the given date. + Returned if the user or non-working time record does not exist, + or if the requesting user does not have permission to view it. diff --git a/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb b/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb index b4a143815cb..ee539c37a29 100644 --- a/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb +++ b/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb @@ -68,6 +68,8 @@ module API .find(declared_params[:non_working_time_id]) end + patch &::API::V3::Utilities::Endpoints::Update.new(model: ::UserNonWorkingTime).mount + delete &::API::V3::Utilities::Endpoints::Delete.new(model: ::UserNonWorkingTime).mount end end diff --git a/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb b/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb index b3395fc9159..d5077de298f 100644 --- a/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb @@ -53,6 +53,11 @@ RSpec.describe API::V3::UserNonWorkingTimes::NonWorkingTimesByUserAPI do expect(last_response).to have_http_status(404) end + it "returns 404 for PATCH /api/v3/users/:user_id/non_working_times/:id" do + patch api_v3_paths.user_non_working_time(target_user.id, non_working_time.id), {}.to_json, headers + expect(last_response).to have_http_status(404) + end + it "returns 404 for DELETE /api/v3/users/:user_id/non_working_times/:id" do delete api_v3_paths.user_non_working_time(target_user.id, non_working_time.id) expect(last_response).to have_http_status(404) @@ -202,6 +207,60 @@ RSpec.describe API::V3::UserNonWorkingTimes::NonWorkingTimesByUserAPI do end end + describe "PATCH /api/v3/users/:user_id/non_working_times/:id" do + let(:path) { api_v3_paths.user_non_working_time(target_user.id, non_working_time.id) } + let(:new_start_date) { (Date.tomorrow + 2.months).iso8601 } + let(:new_end_date) { (Date.tomorrow + 2.months + 4.days).iso8601 } + let(:valid_params) { { startDate: new_start_date, endDate: new_end_date } } + + context "with admin user" do + current_user { admin_user } + + before { patch path, valid_params.to_json, headers } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "updates the non-working time" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserNonWorkingTime") + expect(parsed["startDate"]).to eq(new_start_date) + expect(parsed["endDate"]).to eq(new_end_date) + end + end + + context "with 'me' as the user ID with manage_own_working_times permission" do + let(:own_user) { create(:user, global_permissions: [:manage_own_working_times]) } + let!(:own_time) { create(:user_non_working_time, user: own_user, start_date: Date.tomorrow + 2.weeks) } + + current_user { own_user } + + before { patch api_v3_paths.user_non_working_time("me", own_time.id), valid_params.to_json, headers } + + it "returns 200 OK" do + expect(last_response).to have_http_status(200) + end + + it "updates the non-working time for the current user" do + parsed = JSON.parse(last_response.body) + expect(parsed["_type"]).to eq("UserNonWorkingTime") + expect(parsed["startDate"]).to eq(new_start_date) + expect(parsed["endDate"]).to eq(new_end_date) + end + end + + context "with regular user (no access to other users)" do + current_user { create(:user) } + + before { patch path, valid_params.to_json, headers } + + it "returns 404 since the target user is not visible" do + expect(last_response).to have_http_status(404) + end + end + end + describe "DELETE /api/v3/users/:user_id/non_working_times/:id" do let(:path) { api_v3_paths.user_non_working_time(target_user.id, non_working_time.id) } From 35bf75a62adac5114e90edd32ce7877a942bc86c Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 15:06:35 +0100 Subject: [PATCH 096/435] Show error on the shared field --- .../users/working_hours/days_and_hours_form.rb | 11 ++++++++++- config/locales/en.yml | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/components/users/working_hours/days_and_hours_form.rb b/app/components/users/working_hours/days_and_hours_form.rb index b09e2aa3c98..e42ee2ef8e5 100644 --- a/app/components/users/working_hours/days_and_hours_form.rb +++ b/app/components/users/working_hours/days_and_hours_form.rb @@ -74,9 +74,11 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm ) end + copy_day_errors_to_shared_hours + form.group(data: { "users--working-hours-form-target": "sameHoursSection" }) do |group| group.text_field name: :shared_hours, - label: I18n.t("users.working_hours.form.work_hours"), + label: UserWorkingHours.human_attribute_name(:shared_hours), input_width: :large, value: shared_hours, data: { @@ -142,6 +144,13 @@ class Users::WorkingHours::DaysAndHoursForm < ApplicationForm first_enabled ? day_hours(first_enabled) : "#{Setting.hours_per_day.round(2)}h" end + def copy_day_errors_to_shared_hours + UserWorkingHours::DAYS + .flat_map { |day| model.errors[:"#{day}_hours"] } + .uniq + .each { |message| model.errors.add(:shared_hours, message) } + end + def full_day_name(day) I18n.t("date.day_names")[UserWorkingHours::DAY_ABBR_INDEX[day]] end diff --git a/config/locales/en.yml b/config/locales/en.yml index cc09993d580..835c2f781d9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1844,6 +1844,7 @@ en: sunday: "Sunday" sunday_hours: "Sunday hours" availability_factor: "Availability factor" + shared_hours: "Work hours" version: effective_date: "Finish date" sharing: "Sharing" From 0a843a993ecf176f9fec472ef337fde22adc894d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 15:11:07 +0100 Subject: [PATCH 097/435] Correct response when a user with the `manage_own_working_times` requests times for another user (previously was empty collection) --- .../non_working_times_by_user_api.rb | 1 + .../user_working_hours/working_hours_by_user_api.rb | 1 + .../non_working_times_by_user_api_spec.rb | 12 ++++++++++++ .../working_hours_by_user_api_spec.rb | 12 ++++++++++++ 4 files changed, 26 insertions(+) diff --git a/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb b/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb index ee539c37a29..bfd500b67d9 100644 --- a/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb +++ b/lib/api/v3/user_non_working_times/non_working_times_by_user_api.rb @@ -35,6 +35,7 @@ module API resource :non_working_times do after_validation do guard_feature_flag :user_working_times + raise API::Errors::NotFound unless @user == current_user || current_user.allowed_globally?(:manage_working_times) end params do diff --git a/lib/api/v3/user_working_hours/working_hours_by_user_api.rb b/lib/api/v3/user_working_hours/working_hours_by_user_api.rb index 892a95a1101..8752a79d790 100644 --- a/lib/api/v3/user_working_hours/working_hours_by_user_api.rb +++ b/lib/api/v3/user_working_hours/working_hours_by_user_api.rb @@ -35,6 +35,7 @@ module API resource :working_hours do after_validation do guard_feature_flag :user_working_times + raise API::Errors::NotFound unless @user == current_user || current_user.allowed_globally?(:manage_working_times) end get do diff --git a/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb b/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb index d5077de298f..78caec0a625 100644 --- a/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_non_working_times/non_working_times_by_user_api_spec.rb @@ -129,6 +129,18 @@ RSpec.describe API::V3::UserNonWorkingTimes::NonWorkingTimesByUserAPI do end end + context "with manage_own_working_times viewing another user's records" do + let(:other_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + current_user { other_user } + + before { get path } + + it "returns 404" do + expect(last_response).to have_http_status(404) + end + end + context "with year filter" do current_user { admin_user } diff --git a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb index a110a34b6e1..6a46c9f84be 100644 --- a/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb +++ b/spec/requests/api/v3/user_working_hours/working_hours_by_user_api_spec.rb @@ -120,6 +120,18 @@ RSpec.describe API::V3::UserWorkingHours::WorkingHoursByUserAPI do end end + context "with manage_own_working_times viewing another user's records" do + let(:other_user) { create(:user, global_permissions: [:manage_own_working_times]) } + + current_user { other_user } + + before { get path } + + it "returns 404" do + expect(last_response).to have_http_status(404) + end + end + context "with 'me' as the user ID" do current_user { target_user } From ff015344dc05808e9729d11c8255218013089961 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 11 Mar 2026 15:22:04 +0100 Subject: [PATCH 098/435] Fix specs --- ...user_non_working_time_collection_model.yml | 10 ++++--- spec/models/user_working_hours_spec.rb | 29 +++++++++++++++---- spec/support/pages/users/non_working_times.rb | 2 +- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml b/docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml index 1d302ca1340..a45cf41b9c0 100644 --- a/docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml +++ b/docs/api/apiv3/components/schemas/user_non_working_time_collection_model.yml @@ -41,19 +41,21 @@ example: elements: - _type: UserNonWorkingTime id: 7 - date: "2025-06-18" + startDate: "2025-06-16" + endDate: "2025-06-20" _links: self: - href: /api/v3/users/42/non_working_times/2025-06-16 + href: /api/v3/users/42/non_working_times/7 user: href: /api/v3/users/42 title: Jane Doe - _type: UserNonWorkingTime id: 8 - date: "2025-12-24" + startDate: "2025-12-24" + endDate: "2025-12-24" _links: self: - href: /api/v3/users/42/non_working_times/2025-12-24 + href: /api/v3/users/42/non_working_times/8 user: href: /api/v3/users/42 title: Jane Doe diff --git a/spec/models/user_working_hours_spec.rb b/spec/models/user_working_hours_spec.rb index f883e7e50b8..027575d7b7e 100644 --- a/spec/models/user_working_hours_spec.rb +++ b/spec/models/user_working_hours_spec.rb @@ -38,13 +38,32 @@ RSpec.describe UserWorkingHours do it { is_expected.to validate_presence_of(:valid_from) } + # The *_hours virtual attributes have a converting setter (hours → minutes), so + # shoulda-matchers cannot induce invalid states through it. We bypass the setter + # and write directly to the underlying minute column instead. %i[monday tuesday wednesday thursday friday saturday sunday].each do |day| - it { is_expected.to validate_presence_of(:"#{day}_hours") } + describe "##{day}_hours" do + it "is invalid when exceeding 24 hours" do + subject.public_send(:"#{day}=", (24.5 * 60).round) + expect(subject).not_to be_valid + expect(subject.errors[:"#{day}_hours"]).to be_present + end - it do - expect(subject).to validate_numericality_of(:"#{day}_hours") - .is_greater_than_or_equal_to(0) - .is_less_than_or_equal_to(24) + it "is invalid when negative" do + subject.public_send(:"#{day}=", -60) + expect(subject).not_to be_valid + expect(subject.errors[:"#{day}_hours"]).to be_present + end + + it "is valid at 0 hours" do + subject.public_send(:"#{day}=", 0) + expect(subject).to be_valid + end + + it "is valid at 24 hours" do + subject.public_send(:"#{day}=", 24 * 60) + expect(subject).to be_valid + end end end diff --git a/spec/support/pages/users/non_working_times.rb b/spec/support/pages/users/non_working_times.rb index 29fa5bf748a..f5ae2814fe7 100644 --- a/spec/support/pages/users/non_working_times.rb +++ b/spec/support/pages/users/non_working_times.rb @@ -135,7 +135,7 @@ module Pages end def expect_working_days_count(count) - expect(page).to have_field(I18n.t(:label_working_days), disabled: true, with: count.to_s) + expect(page).to have_field(I18n.t(:label_working_days), with: count.to_s) end def expect_sidebar_entry(text) From b925c617012ee144354e7219cb13ccc7bfb829b6 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Mar 2026 08:08:25 +0100 Subject: [PATCH 099/435] Update DragHandle to match new specification --- .../app/components/backlogs/story_component.html.erb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index d18cd643c73..05381aa1c48 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -32,12 +32,8 @@ See COPYRIGHT and LICENSE files for more details. <%= render( Primer::OpenProject::DragHandle.new( - role: "button", classes: "op-backlogs-story--drag_handle_button", - tabindex: 0, - aria: { - label: t(".label_drag_story", name: story.subject) - } + label: t(".label_drag_story", name: story.subject) ) ) %> From eb60f205e6d2b4010705ed601ff48477594ada42 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Mar 2026 11:16:04 +0100 Subject: [PATCH 100/435] Add HierarchyFields for inplaceEditComponents --- .../common/inplace_edit_field_component.rb | 5 +- .../base_field_component.rb | 4 + .../hierarchy_list_component.rb | 59 +++++++++++ .../hierarchy_list_component.rb | 97 +++++++++++++++++++ .../inplace_edit_fields_controller.rb | 37 ++++++- config/initializers/inplace_edit_fields.rb | 4 +- 6 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb create mode 100644 app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index 7d099b2b9c8..c60e121bb38 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -56,6 +56,7 @@ module OpenProject @system_arguments[:id] = system_arguments[:id] || SecureRandom.uuid @system_arguments[:required] ||= required? @system_arguments[:label] ||= field_label + @system_arguments[:truncated] = truncated end def field_class @@ -125,7 +126,7 @@ module OpenProject end def open_in_dialog? - @open_in_dialog || (custom_field? && custom_field&.has_comment?) + @open_in_dialog || field_class.open_in_dialog? || (custom_field? && custom_field&.has_comment?) end def dialog_edit_url @@ -135,7 +136,7 @@ module OpenProject model: model.class.name, id: model.id, attribute:, - system_arguments_json: @system_arguments.except(:id).to_json + system_arguments_json: @system_arguments.except(:id).merge(page_component_id: @system_arguments[:id]).to_json ) end diff --git a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb index d6cde28f258..fe74be532bb 100644 --- a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb @@ -38,6 +38,10 @@ module OpenProject DisplayFields::DisplayFieldComponent end + def self.open_in_dialog? + false + end + def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments) super() @form = form diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb new file mode 100644 index 00000000000..f94c2bfac33 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + module DisplayFields + class HierarchyListComponent < DisplayFieldComponent + def render_display_value + items = hierarchy_items + + if items.empty? + t("placeholders.default") + elsif custom_field.multi_value? + items.join(", ") + else + items.first.to_s + end + end + + private + + def hierarchy_items + custom_field_values.filter_map do |cv| + CustomField::Hierarchy::Item.find_by(id: cv.value&.to_i) + end + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb b/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb new file mode 100644 index 00000000000..caeda6006f5 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + class HierarchyListComponent < BaseFieldComponent + include CustomFieldHierarchyTreeViewHelper + + def self.display_class + DisplayFields::HierarchyListComponent + end + + def self.open_in_dialog? + true + end + + def initialize(form:, attribute:, model:, show_action_buttons: false, **system_arguments) + super + end + + def call + form_field_name = "project[custom_field_values][]" + + form.hidden(name: form_field_name, value: "", scope_name_to_model: false) + filterable_tree_view(form) + comment_field_if_enabled(form) + end + + private + + def filterable_tree_view(form) + form.html_content do + render(Primer::OpenProject::FilterableTreeView.new( + form_arguments: { builder: rails_builder, name: "custom_field_values" }, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: { hidden: true } + )) do |tree_view| + item_options = { + expanded_fn: ->(*) { true }, + label_fn:, + checked_fn:, + select_variant: custom_field.multi_value? ? :multiple : :single + } + + populate_tree_view(tree_view, custom_field, item_options:) + end + end + end + + # Primer's FormObject stores the underlying ActionView/Primer form builder + # as @builder. FilterableTreeView requires an ActionView::FormBuilder to + # generate its hidden form inputs via hidden_field. + def rails_builder + form.instance_variable_get(:@builder) + end + + def checked_fn + current_values = Array(model.custom_value_for(custom_field)).map(&:value) + lambda { |item| current_values.include?(item.id.to_s) } + end + + def label_fn + item_formatter = standard_tree_view_item_formatter + lambda { |item| item_formatter.format(item:) } + end + end + end + end +end diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index c9d965e2e62..17c2b101828 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -61,6 +61,7 @@ class InplaceEditFieldsController < ApplicationController render_success_flash_message_via_turbo_stream( message: I18n.t(:notice_successful_update) ) + close_dialog_via_turbo_stream(dialog_id) if dialog_id end replace_via_turbo_stream( @@ -145,13 +146,19 @@ class InplaceEditFieldsController < ApplicationController custom_field_id = @attribute.to_s.delete_prefix("custom_field_") # Strong Parameters doesn't support dynamic keys in nested hashes - # So we extract the value directly from the raw params - raw_value = params.dig(model_key, :custom_field_values, custom_field_id) + # So we extract the value directly from the raw params. + # Two formats are supported: + # - Array format: project[custom_field_values][] (used by FilterableTreeView / hierarchy fields) + # - Hash format: project[custom_field_values][{id}] (used by SelectList / legacy fields_for) + cf_values = params.dig(model_key, :custom_field_values) + raw_value = cf_values.is_a?(Array) ? cf_values : cf_values&.dig(custom_field_id) # Handle both single-select and multi-select processed_value = if raw_value.is_a?(Array) - # Remove empty strings from the hidden field - cleaned_values = raw_value.compact_blank + # Remove empty strings from the hidden field, then extract the actual value. + # FilterableTreeView encodes each selected item as a JSON payload + # {"path":[...],"value":""} — extract only the "value" field. + cleaned_values = raw_value.compact_blank.filter_map { |v| extract_tree_view_value(v) } # For single-select, unwrap the array to get the single value cleaned_values.size <= 1 ? cleaned_values.first : cleaned_values else @@ -161,16 +168,36 @@ class InplaceEditFieldsController < ApplicationController { @attribute => processed_value } end + def extract_tree_view_value(raw) + parsed = JSON.parse(raw) + parsed.is_a?(Hash) ? parsed["value"] : raw + rescue JSON::ParserError + raw + end + def component(enforce_edit_mode: false) + args = system_arguments.to_h.symbolize_keys + + # When saving from a dialog, restore the page component's id so the Turbo + # Stream replacement targets the correct wrapper on the page. Also strip + # dialog-specific arguments that must not bleed into the display component. + args[:id] = args.delete(:page_component_id) if args[:page_component_id] + args = args.except(:wrapper_id, :form_id) + OpenProject::Common::InplaceEditFieldComponent.new( model: @model, attribute: @attribute, enforce_edit_mode:, update_registry:, - **system_arguments.to_h.symbolize_keys + **args ) end + def dialog_id + wrapper_id = system_arguments.to_h["wrapper_id"] + wrapper_id&.delete_prefix("#") + end + def update_registry @update_registry ||= OpenProject::InplaceEdit::UpdateRegistry.default end diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index 9664e02c003..c038e12ec75 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -43,8 +43,8 @@ Rails.application.config.to_prepare do "date" => OpenProject::Common::InplaceEditFields::DateInputComponent, "bool" => OpenProject::Common::InplaceEditFields::BooleanInputComponent, "link" => OpenProject::Common::InplaceEditFields::LinkInputComponent, - "hierarchy" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO - "weighted_item_list" => OpenProject::Common::InplaceEditFields::TextInputComponent, # TODO + "hierarchy" => OpenProject::Common::InplaceEditFields::HierarchyListComponent, + "weighted_item_list" => OpenProject::Common::InplaceEditFields::HierarchyListComponent, "list" => OpenProject::Common::InplaceEditFields::SelectListComponent, "user" => OpenProject::Common::InplaceEditFields::UserSelectListComponent, "version" => OpenProject::Common::InplaceEditFields::VersionSelectListComponent, From 2e2ed4eef98584ca1116297ae12e5b8a5c75ec72 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Mar 2026 11:20:34 +0100 Subject: [PATCH 101/435] Avoid doubled initialisation of displayFields --- .../open_project/common/inplace_edit_field_component.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index c60e121bb38..d76c6a60683 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -84,8 +84,10 @@ module OpenProject def display_field_component return nil if display_field_class.nil? - additional_args = open_in_dialog? ? dialog_display_arguments : {} - display_field_class.new(model:, attribute:, writable: writable?, truncated:, **@system_arguments.merge(additional_args)) + @display_field_component ||= begin + additional_args = open_in_dialog? ? dialog_display_arguments : {} + display_field_class.new(model:, attribute:, writable: writable?, truncated:, **@system_arguments.merge(additional_args)) + end end def wrapper_key From c6d43ac72a6724e49de32d3fb196310699e29834 Mon Sep 17 00:00:00 2001 From: ulferts Date: Tue, 10 Mar 2026 16:49:09 +0100 Subject: [PATCH 102/435] sprints in work package schema incl. project specific sprints endpoint --- modules/backlogs/app/models/agile/sprint.rb | 2 - modules/backlogs/config/locales/en.yml | 5 +- .../api/v3/sprints/sprints_by_project_api.rb | 52 ++++++++++ .../lib/open_project/backlogs/engine.rb | 19 +++- .../patches/api/work_package_representer.rb | 6 +- .../api/work_package_schema_representer.rb | 15 ++- .../work_package_schema_representer_spec.rb | 96 ++++++++++++++++++- .../spec/features/impediments_spec.rb | 1 + .../lib/api/v3/utilities/path_helper_spec.rb | 6 ++ ...work_package_representer_rendering_spec.rb | 10 +- .../v3/sprints/project_index_resource_spec.rb | 78 +++++++++++++++ 11 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 modules/backlogs/lib/api/v3/sprints/sprints_by_project_api.rb create mode 100644 modules/backlogs/spec/requests/api/v3/sprints/project_index_resource_spec.rb diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index f757080dd0d..75bf1fdc699 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -44,8 +44,6 @@ module Agile order(arel_table[:start_date].asc.nulls_last, arel_table[:finish_date].asc.nulls_last) end - # FIXME: replace this stub with a meaningful implementation. - scope :visible, -> { all } enum :status, { diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 781959c85f3..5f11401e344 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -47,9 +47,10 @@ en: sprint: duration: "Sprint duration" work_package: - position: "Position" - story_points: "Story Points" backlogs_work_package_type: "Backlog type" + position: "Position" + sprint: "Sprint" + story_points: "Story Points" errors: models: diff --git a/modules/backlogs/lib/api/v3/sprints/sprints_by_project_api.rb b/modules/backlogs/lib/api/v3/sprints/sprints_by_project_api.rb new file mode 100644 index 00000000000..1d2d651acef --- /dev/null +++ b/modules/backlogs/lib/api/v3/sprints/sprints_by_project_api.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Sprints + class SprintsByProjectAPI < ::API::OpenProjectAPI + resources :sprints do + after_validation do + guard_feature_flag(:scrum_projects) + + authorize_in_project(:view_sprints, project: @project) + end + + get &::API::V3::Utilities::Endpoints::Index + .new( + model: Agile::Sprint, + scope: -> { Agile::Sprint.for_project(@project) } + ) + .mount + end + end + end + end +end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index c1281958d44..19f0d93ba76 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -189,10 +189,18 @@ module OpenProject::Backlogs "#{root}/sprints" end + add_api_path :project_sprints do |id| + "#{root}/projects/#{id}/sprints" + end + add_api_endpoint "API::V3::Root" do mount ::API::V3::Sprints::SprintsAPI end + add_api_endpoint "API::V3::Projects::ProjectsAPI", :id do + mount ::API::V3::Sprints::SprintsByProjectAPI + end + config.to_prepare do OpenProject::Backlogs::Hooks::LayoutHook OpenProject::Backlogs::Hooks::UserSettingsHook @@ -208,9 +216,18 @@ module OpenProject::Backlogs end end + story_and_sprint_permission = ->(type, project: nil) do + if project.present? + type.story? && User.current.allowed_in_project?(:view_sprints, project) + else + # Allow globally configuring the attribute if story + type.story? + end + end + ::Type.add_constraint :position, enabled_backlogs_story ::Type.add_constraint :story_points, enabled_backlogs_story - ::Type.add_constraint :sprint, enabled_backlogs_story + ::Type.add_constraint :sprint, story_and_sprint_permission ::Type.add_default_mapping(:estimates_and_progress, :story_points) ::Type.add_default_mapping(:other, :position) diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb index 65f23232750..36adf0f38ce 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb @@ -48,7 +48,8 @@ module OpenProject::Backlogs resource :sprint, link_cache_if: ->(*) { - current_user.allowed_in_project?(:view_sprints, represented.project) + current_user.allowed_in_project?(:view_sprints, represented.project) && + OpenProject::FeatureDecisions.scrum_projects_active? }, link: ->(*) { next unless represented.type&.passes_attribute_constraint?(:sprint) @@ -68,7 +69,8 @@ module OpenProject::Backlogs if embed_links && represented.sprint.present? && represented.type&.passes_attribute_constraint?(:story_points) && - current_user.allowed_in_project?(:view_sprints, represented.project) + current_user.allowed_in_project?(:view_sprints, represented.project) && + OpenProject::FeatureDecisions.scrum_projects_active? ::API::V3::Sprints::SprintRepresenter.create(represented.sprint, current_user:) end end, diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb index 8e242495785..91f6cabd3b2 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb @@ -38,7 +38,6 @@ module OpenProject::Backlogs schema :position, type: "Integer", required: false, - writable: false, show_if: ->(*) { backlogs_constraint_passed?(:position) } @@ -50,9 +49,19 @@ module OpenProject::Backlogs backlogs_constraint_passed?(:story_points) } + schema_with_allowed_link :sprint, + has_default: false, + required: false, + show_if: ->(*) { + backlogs_constraint_passed?(:sprint) && + OpenProject::FeatureDecisions.scrum_projects_active? + }, + href_callback: ->(*) { + api_v3_paths.project_sprints(represented.project_id) + } + define_method :backlogs_constraint_passed? do |attribute| - represented.project&.backlogs_enabled? && - (!represented.type || represented.type.passes_attribute_constraint?(attribute)) + !represented.type || represented.type.passes_attribute_constraint?(attribute, project: represented.project) end end end diff --git a/modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb b/modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb index 70ad050bdc2..8f9a54b7a88 100644 --- a/modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb +++ b/modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb @@ -28,19 +28,23 @@ require "spec_helper" -RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do +RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with_flag: { scrum_projects: true } do + include API::V3::Utilities::PathHelper + let(:custom_field) { build(:custom_field) } let(:schema) do API::V3::WorkPackages::Schema::SpecificWorkPackageSchema.new(work_package:) end - let(:representer) { described_class.create(schema, self_link: nil, current_user:) } + let(:representer) { described_class.create(schema, form_embedded: true, self_link: nil, current_user:) } + let(:project) { work_package.project } let(:work_package) { build_stubbed(:work_package, type: build_stubbed(:type)) } let(:current_user) { build_stubbed(:user) } + let(:permissions) { %i(view_work_packages edit_work_packages view_sprints manage_sprint_items) } before do mock_permissions_for(current_user) do |mock| - mock.allow_in_project :edit_work_packages, project: work_package.project + mock.allow_in_project *permissions, project: end login_as(current_user) @@ -50,9 +54,9 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do allow(work_package).to receive(:leaf?).and_return(true) end - describe "storyPoints" do - subject { representer.to_json } + subject { representer.to_json } + describe "storyPoints" do it_behaves_like "has basic schema properties" do let(:path) { "storyPoints" } let(:type) { "Integer" } @@ -81,4 +85,86 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do end end end + + describe "position" do + it_behaves_like "has basic schema properties" do + let(:path) { "position" } + let(:type) { "Integer" } + let(:name) { I18n.t("activerecord.attributes.work_package.position") } + let(:required) { false } + let(:writable) { false } + end + + context "when backlogs module is disabled" do + before do + allow(schema.project).to receive(:backlogs_enabled?).and_return(false) + end + + it "does not show position" do + expect(subject).not_to have_json_path("position") + end + end + + context "when not a story" do + before do + allow(schema.type).to receive(:story?).and_return(false) + end + + it "does not show position" do + expect(subject).not_to have_json_path("position") + end + end + end + + describe "sprint" do + let(:path) { "sprint" } + + it_behaves_like "has basic schema properties" do + let(:type) { "Sprint" } + let(:name) { I18n.t("activerecord.attributes.work_package.sprint") } + let(:required) { false } + let(:writable) { true } + let(:location) { "_links" } + end + + it_behaves_like "links to allowed values via collection link" do + let(:href) { api_v3_paths.project_sprints(project.id) } + end + + context "when lacking permission to set the sprint" do + let(:permissions) { %i(view_work_packages edit_work_packages view_sprints) } + + it_behaves_like "has basic schema properties" do + let(:type) { "Sprint" } + let(:name) { I18n.t("activerecord.attributes.work_package.sprint") } + let(:required) { false } + let(:writable) { false } + let(:location) { "_links" } + end + end + + context "when lacking permission to see the sprints (or if backlogs is disabled)" do + let(:permissions) { %i(view_work_packages edit_work_packages) } + + it "has no reference to the sprint" do + expect(subject).not_to have_json_path(path) + end + end + + context "when the feature flag is disabled", with_flag: { scrum_projects: false } do + it "has no reference to the sprint" do + expect(subject).not_to have_json_path(path) + end + end + + context "when not a story" do + before do + allow(schema.type).to receive(:story?).and_return(false) + end + + it "does not show sprint" do + expect(subject).not_to have_json_path("sprint") + end + end + end end diff --git a/modules/backlogs/spec/features/impediments_spec.rb b/modules/backlogs/spec/features/impediments_spec.rb index e54a0d002cf..cbdd37965a3 100644 --- a/modules/backlogs/spec/features/impediments_spec.rb +++ b/modules/backlogs/spec/features/impediments_spec.rb @@ -59,6 +59,7 @@ RSpec.describe "Impediments on taskboard", :js, view_work_packages edit_work_packages manage_subtasks + assign_versions work_package_assigned)) end let!(:current_user) do diff --git a/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb b/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb index f30a02a7fa3..4cf9ed2c88d 100644 --- a/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -37,5 +37,11 @@ RSpec.describe API::V3::Utilities::PathHelper do describe "sprint paths" do it_behaves_like "index", :sprint it_behaves_like "show", :sprint + + describe "#project_sprints" do + subject { helper.project_sprints 1 } + + it_behaves_like "api v3 path", "/projects/1/sprints" + end end end diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb index e94c967dd28..e5a5ca5197c 100644 --- a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb @@ -32,7 +32,7 @@ require "spec_helper" # Only tests the links/properties added by the backlogs plugin. It does not retest the properties already # covered in the core. -RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering" do +RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_flag: { scrum_projects: true } do include API::V3::Utilities::PathHelper let(:work_package) do @@ -121,6 +121,10 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering" do it_behaves_like "has no link" end + context "when the feature flag is inactive", with_flag: { scrum_projects: false } do + it_behaves_like "has no link" + end + context "when it is a task" do let(:type) { task_type } @@ -161,6 +165,10 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering" do it_behaves_like "has the resource not embedded" end + context "when the feature flag is inactive", with_flag: { scrum_projects: false } do + it_behaves_like "has the resource not embedded" + end + context "when it is a type" do let(:type) { task_type } diff --git a/modules/backlogs/spec/requests/api/v3/sprints/project_index_resource_spec.rb b/modules/backlogs/spec/requests/api/v3/sprints/project_index_resource_spec.rb new file mode 100644 index 00000000000..4be6d08774e --- /dev/null +++ b/modules/backlogs/spec/requests/api/v3/sprints/project_index_resource_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Sprint resource on project", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:other_project) { create(:project, public: false) } + shared_let(:project_without_permission) { create(:project, public: false) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:other_sprint) { create(:agile_sprint, project: other_project) } + shared_let(:sprint_without_permission) { create(:agile_sprint, project: project_without_permission) } + + let(:permissions) { %i[view_sprints] } + + current_user do + create(:user, + member_with_permissions: { + project => permissions, + other_project => permissions + }) + end + + describe "GET /api/v3/projects/:id/sprints", with_flag: :scrum_projects do + let(:get_path) { api_v3_paths.project_sprints(project.id) } + + before do + get get_path + end + + context "for a user with view_sprints permission" do + it_behaves_like "API V3 collection response", 1, 1, "Sprint" do + let(:elements) { [sprint] } + end + end + + context "for a user without view_sprints permission" do + let(:permissions) { [] } + + it_behaves_like "unauthorized access" + end + + context "when the feature flag is turned off", with_flag: { scrum_projects: false } do + it_behaves_like "not found" + end + end +end From 420b85c6ea185e96caad522073d793ace977d608 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Mar 2026 12:24:23 +0100 Subject: [PATCH 103/435] Avoid that calculated fields are editable at all && update docs --- .../calculated_value_input_component.rb | 3 +- .../calculated_value_input_component.rb | 5 +- .../display_fields/display_field_component.rb | 4 + .../patterns/06-inplace-edit-fields.md.erb | 86 ++++++++++++++++++- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb index f72fafa0790..02e45b288ce 100644 --- a/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/calculated_value_input_component.rb @@ -39,7 +39,8 @@ module OpenProject def initialize(form:, attribute:, model:, **system_arguments) system_arguments ||= {} system_arguments[:readonly] = true - super + + super(form:, attribute:, model:, show_action_buttons: false, **system_arguments) end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb index 3810bcec98a..ea00fc6b63d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb @@ -37,9 +37,8 @@ module OpenProject attr_reader :model, :attribute - def initialize(model:, attribute:, **system_arguments) - @system_arguments = system_arguments - super(model:, attribute:, writable: false, **system_arguments) + def initialize(model:, attribute:, writable: nil, truncated: false, **system_arguments) + super(model:, attribute:, writable: false, truncated:, **system_arguments) end def render_tooltip diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 92570099329..6992bf3a70d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -80,6 +80,8 @@ module OpenProject end def dialog_field_arguments + return {} unless writable? + { data: { controller: "inplace-edit async-dialog", @@ -97,6 +99,8 @@ module OpenProject end def inline_edit_field_arguments + return {} unless writable? + { data: { controller: "inplace-edit", diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index ceefb0b6262..f2eb0d1da2f 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -12,7 +12,7 @@ The InplaceEdit system consists of: - **A base class for edit field components** (`BaseFieldComponent`) - **Edit field components** - (e.g. `TextInputComponent`, `RichTextAreaComponent`) + (e.g. `TextInputComponent`, `RichTextAreaComponent`, `HierarchyListComponent`) - **Optional display field components** - **A dialog component** (`InplaceEditFieldDialogComponent`) @@ -52,7 +52,12 @@ The component resolves the edit field via the `FieldRegistry` and optionally a d **Automatic dialog mode:** -The component switches to dialog mode automatically (regardless of the `open_in_dialog` parameter) when the attribute is a custom field that has comments enabled (`custom_field.has_comment?`). This ensures that the comment field is always presented alongside the value field in a dialog. +The component switches to dialog mode automatically (regardless of the `open_in_dialog` parameter) in any of these cases: + +- The registered edit field component returns `true` from `self.open_in_dialog?` +- The attribute is a custom field that has comments enabled (`custom_field.has_comment?`) + +This allows individual field components to declare that they always require a dialog (e.g. because they embed a complex tree picker), without the caller needing to know about it. **Simplified HTML of the `InplaceEditFieldComponent`:** ```html @@ -100,6 +105,13 @@ OpenProject::InplaceEdit::FieldRegistry.register( It handles the optional rendering of a comment field for custom fields with comments enabled. +**Class-level API:** + +| Method | Default | Description | +|---|---|---| +| `self.display_class` | `DisplayFields::DisplayFieldComponent` | The display component class to use for the read-only view | +| `self.open_in_dialog?` | `false` | Return `true` to force the field to always open in a dialog regardless of the caller's settings | + ```ruby module OpenProject module Common @@ -109,6 +121,10 @@ module OpenProject DisplayFields::DisplayFieldComponent end + def self.open_in_dialog? + false # override to true for dialog-only fields + end + def call form.text_field(name: attribute, **@system_arguments) comment_field_if_enabled(form) @@ -166,6 +182,64 @@ module OpenProject end ``` +#### HierarchyListComponent (example of a dialog-only field) + +`HierarchyListComponent` is the edit field for hierarchy-type custom fields. It uses `Primer::OpenProject::FilterableTreeView` to render a filterable, hierarchical tree picker. Because the tree picker is a complex, non-inline widget, the component declares `self.open_in_dialog? = true` so it always opens in a dialog. + +**Key patterns used:** + +- **`self.open_in_dialog?` → `true`**: Tells `InplaceEditFieldComponent` to always use dialog mode for this field. +- **`form.html_content`**: Embeds arbitrary rendered HTML (the `FilterableTreeView`) within the Primer form DSL `call` method. +- **`rails_builder`**: `FilterableTreeView` requires an ActionView `FormBuilder` (not a Primer `FormObject`). The underlying builder is accessed via `form.instance_variable_get(:@builder)`. +- **Array form encoding**: The hidden sentinel field and tree view items both use `project[custom_field_values][]` (array format). The controller handles this with `extract_tree_view_value` (see below). + +```ruby +class HierarchyListComponent < BaseFieldComponent + include CustomFieldHierarchyTreeViewHelper + + def self.display_class + DisplayFields::HierarchyListComponent + end + + def self.open_in_dialog? + true + end + + def call + # Hidden sentinel so the param key is always present even with nothing selected + form.hidden(name: "project[custom_field_values][]", value: "", scope_name_to_model: false) + filterable_tree_view(form) + comment_field_if_enabled(form) + end + + private + + def filterable_tree_view(form) + form.html_content do + render(Primer::OpenProject::FilterableTreeView.new( + form_arguments: { builder: rails_builder, name: "custom_field_values" }, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: { hidden: true } + )) do |tree_view| + item_options = { + expanded_fn: ->(*) { true }, + label_fn:, checked_fn:, + select_variant: custom_field.multi_value? ? :multiple : :single + } + populate_tree_view(tree_view, custom_field, item_options:) + end + end + end + + def rails_builder + # Primer's FormObject stores the underlying ActionView/Primer form builder + # as @builder. FilterableTreeView requires an ActionView::FormBuilder to + # generate its hidden form inputs via hidden_field. + form.instance_variable_get(:@builder) + end +end +``` + #### DisplayFieldComponents `DisplayFieldComponents` render the attribute value in read-only mode. They handle formatting and attach the Stimulus controller that triggers the switch to edit mode. @@ -216,6 +290,12 @@ Display component (with dialog trigger) │ └─ footer: Save / Cancel buttons ``` +**`page_component_id` convention:** + +Each `InplaceEditFieldComponent` generates a UUID and stores it in `@system_arguments[:id]`. This UUID is part of the `wrapper_key` that Turbo Stream uses to target the correct DOM element when replacing the component after an update. + +When building the dialog URL, the component passes this UUID as `page_component_id` inside `system_arguments_json` (while excluding its own `:id` key to avoid a clash with the dialog's UUID). On a successful `update`, the controller restores `page_component_id` as `:id` before constructing the response component, so the `wrapper_key` matches the original element on the page and the Turbo Stream replacement lands in the right place. The controller also closes the dialog via `close_dialog_via_turbo_stream` on success, and strips dialog-specific arguments (`wrapper_id`, `form_id`) so they do not bleed into the display component. + ### Update behaviour #### InplaceEditFieldsController @@ -274,4 +354,4 @@ No changes to the core component or controller should be required. ## Supporting new models To support a new model, implement an update handler and a contract and register both in the `UpdateRegistry`. -No changes to the core component or controller should be required. \ No newline at end of file +No changes to the core component or controller should be required. From d3949d966efd9b924dce2970eeed34b0d9ea0341 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 12 Mar 2026 12:21:39 +0100 Subject: [PATCH 104/435] move specs to proper location --- .../spec/api/work_package_resource_spec.rb | 102 ------------------ .../work_package_schema_representer_spec.rb | 8 +- .../v3}/work_packages/form_resource_spec.rb | 0 3 files changed, 5 insertions(+), 105 deletions(-) delete mode 100644 modules/backlogs/spec/api/work_package_resource_spec.rb rename modules/backlogs/spec/{api/work_packages => lib/api/v3/work_packages/schema}/work_package_schema_representer_spec.rb (98%) rename modules/backlogs/spec/{api => requests/api/v3}/work_packages/form_resource_spec.rb (100%) diff --git a/modules/backlogs/spec/api/work_package_resource_spec.rb b/modules/backlogs/spec/api/work_package_resource_spec.rb deleted file mode 100644 index 5e21c1cdc35..00000000000 --- a/modules/backlogs/spec/api/work_package_resource_spec.rb +++ /dev/null @@ -1,102 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" -require "rack/test" - -RSpec.describe "API v3 Work package resource" do - include Rack::Test::Methods - include Capybara::RSpecMatchers - - let(:current_user) { create(:admin) } - let(:project) { create(:project) } - let(:work_package) do - create(:work_package, - project:, - story_points: 8, - estimated_hours: 5, - remaining_hours: 5) - end - let(:wp_path) { "/api/v3/work_packages/#{work_package.id}" } - - before do - allow(Story).to receive(:types).and_return([work_package.type_id]) - end - - describe "#get" do - shared_context "query work package" do - before do - allow(User).to receive(:current).and_return(current_user) - get wp_path - end - - subject { last_response.body } - end - - context "backlogs activated" do - include_context "query work package" - - it { is_expected.to be_json_eql(work_package.story_points.to_json).at_path("storyPoints") } - end - - context "backlogs deactivated" do - let(:project) do - create(:project, disable_modules: "backlogs") - end - - include_context "query work package" - - it { expect(last_response).to have_http_status :ok } - - it { is_expected.not_to have_json_path("storyPoints") } - end - end - - describe "#patch" do - let(:valid_params) do - { - _type: "WorkPackage", - lockVersion: work_package.lock_version - } - end - - subject { last_response } - - before do - allow(User).to receive(:current).and_return current_user - patch wp_path, params.to_json, "CONTENT_TYPE" => "application/json" - end - - describe "storyPoints" do - let(:params) { valid_params.merge(storyPoints: 12) } - - it { expect(subject.status).to eq(200) } - it { expect(subject.body).to be_json_eql(12.to_json).at_path("storyPoints") } - end - end -end diff --git a/modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb similarity index 98% rename from modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb rename to modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index 8f9a54b7a88..308dabf4340 100644 --- a/modules/backlogs/spec/api/work_packages/work_package_schema_representer_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -1,4 +1,6 @@ -#-- copyright +# frozen_string_literal: true + +# -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -24,7 +26,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -#++ +# ++ require "spec_helper" @@ -75,7 +77,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "not a story" do + context "when not a story" do before do allow(schema.type).to receive(:story?).and_return(false) end diff --git a/modules/backlogs/spec/api/work_packages/form_resource_spec.rb b/modules/backlogs/spec/requests/api/v3/work_packages/form_resource_spec.rb similarity index 100% rename from modules/backlogs/spec/api/work_packages/form_resource_spec.rb rename to modules/backlogs/spec/requests/api/v3/work_packages/form_resource_spec.rb From 2c7e427f90782ed3134f6eb07d136732a8ccc0c2 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 12 Mar 2026 13:59:41 +0100 Subject: [PATCH 105/435] update comment --- modules/backlogs/lib/open_project/backlogs/engine.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 19f0d93ba76..7033ed108dc 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -169,7 +169,9 @@ module OpenProject::Backlogs extend_api_response(:v3, :work_packages, :work_package, &::OpenProject::Backlogs::Patches::API::WorkPackageRepresenter.extension) - # TODO: check if this can be simply removed as it already gets its info by patching the WPRepresenter + # TODO: This should not be necessary as the WorkPackagePayloadRepresenter already inherits from + # the WorkPackageRepresenter. But removing this line makes tests fail. It appears that the + # patch on the WorkPackageRepresenter in GitHubIntegration is failing if this is removed. extend_api_response(:v3, :work_packages, :work_package_payload, &::OpenProject::Backlogs::Patches::API::WorkPackageRepresenter.extension) From 1f8853a024d437e4d44f8f8e6bd533e7272e209f Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Mar 2026 15:02:03 +0100 Subject: [PATCH 106/435] Support calculatedFields error messages and update them once a dependent field was updated --- .../inplace_edit_field_component.html.erb | 3 +- .../calculated_value_input_component.rb | 20 +++++++++ .../display_field_component.html.erb | 4 ++ .../display_fields/display_field_component.rb | 4 ++ .../inplace_edit_fields_controller.rb | 45 ++++++++++++++++++- .../patterns/06-inplace-edit-fields.md.erb | 30 +++++++++++++ 6 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_field_component.html.erb b/app/components/open_project/common/inplace_edit_field_component.html.erb index a59106cd554..eeae7714d24 100644 --- a/app/components/open_project/common/inplace_edit_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_component.html.erb @@ -4,7 +4,8 @@ uniq_by: wrapper_uniq_by, data: { test_selector: wrapper_test_selector, - turbo_stream_target: wrapper_id + turbo_stream_target: wrapper_id, + inplace_edit_stable_key: wrapper_uniq_by } ) do %> <% if display_field_component.present? && !enforce_edit_mode %> diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb index ea00fc6b63d..be3f4ca2192 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb @@ -34,6 +34,7 @@ module OpenProject module DisplayFields class CalculatedValueInputComponent < DisplayFieldComponent include OpPrimer::ComponentHelpers + include CalculatedValues::ErrorsHelper attr_reader :model, :attribute @@ -41,6 +42,25 @@ module OpenProject super(model:, attribute:, writable: false, truncated:, **system_arguments) end + def render_calculation_error + error = custom_field&.first_calculation_error(model) + return unless error + + render(Primer::OpenProject::FlexLayout.new( + align_items: :flex_start, + data: { test_selector: "error-cf-#{custom_field.id}" } + )) do |container| + container.with_column do + render Primer::Beta::Octicon.new(icon: :"alert-fill", color: :danger) + end + container.with_column(ml: 2) do + render Primer::Beta::Text.new(color: :danger) do + calculated_value_error_msg(error) + end + end + end + end + def render_tooltip render Primer::Alpha::Tooltip.new( for_id: @system_arguments[:id], diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb index c9269d8d7e5..39335eb3c65 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb @@ -27,6 +27,10 @@ ) ) end + + if (error_html = render_calculation_error).present? + flex.with_row(w: :full) { error_html } + end end %> diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 6992bf3a70d..e3756211b4a 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -110,6 +110,10 @@ module OpenProject } end + def render_calculation_error + # no-op — subclasses may override to render a calculation error row + end + def input_specific_call render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do render_display_value diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index 17c2b101828..7324effbe9d 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -62,6 +62,7 @@ class InplaceEditFieldsController < ApplicationController message: I18n.t(:notice_successful_update) ) close_dialog_via_turbo_stream(dialog_id) if dialog_id + refresh_calculated_dependents end replace_via_turbo_stream( @@ -125,13 +126,17 @@ class InplaceEditFieldsController < ApplicationController end end + def custom_field_attribute? + @attribute.to_s.start_with?("custom_field_") + end + def custom_field_via_fields_for? - @attribute.to_s.start_with?("custom_field_") && + custom_field_attribute? && params[@model.model_name.param_key]&.key?(:custom_field_values) end def custom_comments_params - return {} unless @attribute.to_s.start_with?("custom_field_") + return {} unless custom_field_attribute? custom_field_id = @attribute.to_s.delete_prefix("custom_field_") raw_comment = params.dig(@model.model_name.param_key, :custom_comments, custom_field_id) @@ -198,6 +203,42 @@ class InplaceEditFieldsController < ApplicationController wrapper_id&.delete_prefix("#") end + def refresh_calculated_dependents + return unless custom_field_attribute? + return unless @model.respond_to?(:available_custom_fields) + + affected = affected_calculated_fields + return if affected.empty? + + # Inherit presentation args from the submitted system_arguments. + # Fields in the same container share the same context (e.g. both truncated + # in the sidebar), so this preserves the correct display for dependents. + presentation_args = system_arguments.to_h.symbolize_keys.slice(:truncated, :open_in_dialog) + affected.each { |custom_field| turbo_streams << calculated_field_turbo_stream(custom_field, presentation_args) } + end + + def affected_calculated_fields + cf_id = @attribute.to_s.delete_prefix("custom_field_").to_i + @model.available_custom_fields.affected_calculated_fields([cf_id]) + end + + def calculated_field_turbo_stream(custom_field, presentation_args) + attribute = custom_field.attribute_name.to_sym + comp = OpenProject::Common::InplaceEditFieldComponent.new( + model: @model, + attribute:, + update_registry:, + **presentation_args + ) + stable_key = "#{@model.class.name.parameterize(separator: '_')}_#{@model.id}_#{attribute}" + comp.render_as_turbo_stream( + view_context:, + action: :replace, + target: nil, + targets: "[data-inplace-edit-stable-key='#{stable_key}']" + ) + end + def update_registry @update_registry ||= OpenProject::InplaceEdit::UpdateRegistry.default end diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index f2eb0d1da2f..063627e75a8 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -240,6 +240,36 @@ class HierarchyListComponent < BaseFieldComponent end ``` +#### CalculatedValueInputComponent + +`CalculatedValueInputComponent` is a **display-only** field component used for calculated custom fields. Calculated fields derive their value from other fields via a formula — they cannot be edited directly. + +The component always passes `writable: false` to its parent `DisplayFieldComponent`, which prevents the Stimulus inplace-edit controller from being attached to the DOM element. It also renders a non-editable tooltip. + +When a calculation fails, the component renders an inline error beneath the value via the `render_calculation_error` hook. + +**Automatic refresh of calculated fields:** + +Calculated field values depend on other (non-calculated) fields. When a non-calculated field is saved, the controller automatically refreshes all calculated fields that depend on it: + +1. After a successful save, `InplaceEditFieldsController#refresh_calculated_dependents` calls `available_custom_fields.affected_calculated_fields([cf_id])` to find all direct and transitive dependents. +2. For each dependent, a new `InplaceEditFieldComponent` is rendered with the updated value and any calculation errors. +3. The rendered component is pushed as a Turbo Stream `replace` using a CSS selector (`targets:`), targeting every DOM element with a matching `data-inplace-edit-stable-key` attribute. + +The `stable_key` is a deterministic string derived from the model class, model ID, and attribute name (e.g. `project_42_custom_field_18`). Unlike the UUID-based `wrapper_key`, it is consistent across page loads and independent of dialog state, which makes CSS-selector targeting reliable even when the same field appears multiple times on the page. + +``` +Non-calculated field saved (POST /inplace_edit_fields/update) + └─ refresh_calculated_dependents + └─ affected_calculated_fields([cf_id]) → [cf_18, cf_22] + └─ turbo-stream replace targets="[data-inplace-edit-stable-key='project_42_custom_field_18']" + └─ turbo-stream replace targets="[data-inplace-edit-stable-key='project_42_custom_field_22']" +``` + +**Presentation context preservation:** + +Calculated fields have no edit form, so their display settings (`truncated`, `open_in_dialog`) are not available in the DOM when a refresh is triggered. The controller inherits these settings from the `system_arguments_json` of the field being saved — valid because fields in the same container share the same presentation context. + #### DisplayFieldComponents `DisplayFieldComponents` render the attribute value in read-only mode. They handle formatting and attach the Stimulus controller that triggers the switch to edit mode. From ec2fea03fb3dc759aa2a01a97422e466e3541c20 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 12 Mar 2026 15:38:07 +0100 Subject: [PATCH 107/435] remove old implementation of editing project attributes in the sidebar --- modules/overviews/app/components/_index.sass | 1 - .../dialog_component.html.erb | 30 ---- .../project_custom_fields/dialog_component.rb | 71 ---------- .../edit_component.html.erb | 68 ---------- .../project_custom_fields/edit_component.rb | 53 -------- .../edit_dialog_component.rb | 65 --------- .../project_custom_fields/item_component.rb | 69 ---------- .../items_component.html.erb | 1 - .../project_custom_fields/items_component.rb | 5 - .../show_component.html.erb | 77 +---------- .../project_custom_fields/show_component.rb | 128 +----------------- .../project_custom_fields/show_component.sass | 22 --- .../show_dialog_component.rb | 49 ------- .../project_custom_fields_controller.rb | 124 ----------------- modules/overviews/config/routes.rb | 2 - .../project_custom_fields_routing_spec.rb | 117 ---------------- 16 files changed, 8 insertions(+), 874 deletions(-) delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/dialog_component.html.erb delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/dialog_component.rb delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/edit_component.html.erb delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/edit_component.rb delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/edit_dialog_component.rb delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/show_component.sass delete mode 100644 modules/overviews/app/components/overviews/project_custom_fields/show_dialog_component.rb delete mode 100644 modules/overviews/app/controllers/overviews/project_custom_fields_controller.rb delete mode 100644 modules/overviews/spec/routing/project_custom_fields_routing_spec.rb diff --git a/modules/overviews/app/components/_index.sass b/modules/overviews/app/components/_index.sass index 86123d4e063..fa0024bae71 100644 --- a/modules/overviews/app/components/_index.sass +++ b/modules/overviews/app/components/_index.sass @@ -1,3 +1,2 @@ -@import "./overviews/project_custom_fields/show_component.sass" @import "./overviews/project_phases/item_component.sass" @import "./overviews/overview_grid_component.sass" diff --git a/modules/overviews/app/components/overviews/project_custom_fields/dialog_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/dialog_component.html.erb deleted file mode 100644 index 969f2e5775c..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/dialog_component.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%= - render( - Primer::Alpha::Dialog.new( - title: dialog_title, - classes: "Overlay--size-large-portrait", - size: :large, - id: dialog_id - ) - ) do |d| - d.with_header(variant: :large) - d.with_body(classes: "Overlay-body_autocomplete_height") do - render(body_component) - end - d.with_footer do - component_collection do |footer_collection| - footer_collection.with_component( - Primer::Beta::Button.new( - data: { - "close-dialog-id": dialog_id - } - ) - ) do - close_button_title - end - - footer_buttons(footer_collection) - end - end - end -%> diff --git a/modules/overviews/app/components/overviews/project_custom_fields/dialog_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/dialog_component.rb deleted file mode 100644 index 465c17d21fe..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/dialog_component.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Overviews - module ProjectCustomFields - class DialogComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - - def initialize(project:, project_custom_field:) - super - @project = project - @project_custom_field = project_custom_field - end - - private - - def dialog_title - @project_custom_field.project_custom_field_section.name - end - - def dialog_id - "project-custom-field-dialog-#{@project_custom_field.id}" - end - - def wrapper_id - "##{dialog_id}" - end - - def body_component - fail NoMethodError, "Must be overridden in subclass" - end - - def close_button_title - fail NoMethodError, "Must be overridden in subclass" - end - - def footer_buttons(footer_collection) - # noop - end - end - end -end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/edit_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/edit_component.html.erb deleted file mode 100644 index 0e20395db87..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/edit_component.html.erb +++ /dev/null @@ -1,68 +0,0 @@ -<%= helpers.angular_component_tag "opce-custom-modal-overlay" %> -<%= - component_wrapper do - primer_form_with( - id: "project-custom-field-edit-form", - model: @project, - method: :put, - data: { turbo: true, turbo_stream: true, "test-selector": "async-dialog-content" }, - url: project_custom_field_path(project_id: @project.id, id: @project_custom_field.id) - ) do |f| - if @project_custom_field.hierarchical_list? - form_field_name = "project[custom_field_values][]" - concat( - render_inline_form(f) do |hidden_form| - hidden_form.hidden(name: form_field_name, value: "", scope_name_to_model: false) - end - ) - - concat( - render( - Primer::OpenProject::FilterableTreeView.new( - form_arguments: { builder: f, name: "custom_field_values" }, - include_sub_items_check_box_arguments: { hidden: true }, - filter_mode_control_arguments: { hidden: true } - ) - ) do |tree_view| - current_values = Array(@project.custom_value_for(@project_custom_field)).map(&:value) - checked_fn = lambda { |item| current_values.include?(item.id.to_s) } - item_formatter = standard_tree_view_item_formatter - label_fn = lambda { |item| item_formatter.format(item:) } - - item_options = { - expanded_fn: ->(*) { true }, - label_fn:, - checked_fn:, - select_variant: @project_custom_field.multi_value? ? :multiple : :single - } - - populate_tree_view(tree_view, @project_custom_field, item_options:) - end - ) - - if @project_custom_field.has_comment? - concat( - f.fields_for(:custom_comments) do |builder| - render( - CustomFields::CommentField.new( - builder, - custom_field: @project_custom_field, - object: @project - ) - ) - end - ) - end - else - render( - Projects::CustomFields::Form.new( - f, - project: @project, - custom_field: @project_custom_field, - wrapper_id: - ) - ) - end - end - end -%> diff --git a/modules/overviews/app/components/overviews/project_custom_fields/edit_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/edit_component.rb deleted file mode 100644 index c4f97a391bd..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/edit_component.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Overviews - module ProjectCustomFields - class EditComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - include CustomFieldHierarchyTreeViewHelper - - attr_reader :wrapper_id - - def initialize(project:, project_custom_field:, wrapper_id: nil) - super - @project = project - @project_custom_field = project_custom_field - @wrapper_id = wrapper_id - end - - def wrapper_uniq_by - @project_custom_field.id - end - end - end -end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/edit_dialog_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/edit_dialog_component.rb deleted file mode 100644 index 103a8a2f89f..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/edit_dialog_component.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Overviews - module ProjectCustomFields - class EditDialogComponent < DialogComponent - private - - def body_component - Overviews::ProjectCustomFields::EditComponent.new( - project_custom_field: @project_custom_field, - project: @project, - wrapper_id: - ) - end - - def close_button_title - t("button_cancel") - end - - def footer_buttons(footer_collection) - footer_collection.with_component( - Primer::Beta::Button.new( - scheme: :primary, - type: :submit, - form: "project-custom-field-edit-form", - data: { - test_selector: "save-project-attributes-button", - turbo: true - } - ) - ) do - t("button_save") - end - end - end - end -end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb index 426f40a58dd..7e72f9cd05d 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb @@ -36,75 +36,6 @@ module Overviews def limited_space? @project_custom_field.field_format == "text" && @project_custom_field.project_custom_field_section&.shown_in_overview_sidebar? - - end - - def show_comment? = false - - def value_wrapper_attributes - if allowed_to_edit? - if calculated_value? && !has_comment? - non_editable_wrapper(id: calculated_value_tooltip_id) - else - modal_wrapper - end - elsif has_comment? - modal_wrapper - else - non_editable_wrapper - end - end - - def allowed_to_edit? - User.current.allowed_in_project?(:edit_project_attributes, @project) - end - - def modal_wrapper - action_label_key = allowed_to_edit? ? :label_edit_x : :label_view_x - - url = if allowed_to_edit? - edit_project_custom_field_path(project_id: @project, id: @project_custom_field) - else - project_custom_field_path(project_id: @project, id: @project_custom_field) - end - - { - tag: :div, - classes: "project-custom-field-clickable", - data: { - controller: "project-custom-field-modal async-dialog", - "project-custom-field-modal-url-value": url, - action: "click->project-custom-field-modal#open " \ - "keydown.enter->project-custom-field-modal#open " \ - "keydown.space->project-custom-field-modal#open " \ - "project-custom-field-modal:open-dialog->async-dialog#handleOpenDialog" - }, - aria: { - label: [ - I18n.t(action_label_key, x: @project_custom_field.name), - I18n.t(:label_value_x, x: accessible_value_text) - ].join(", ") - }, - role: "button", - tabindex: 0, - test_selector: "project-custom-field-modal-button-#{@project_custom_field.id}" - } - end - - def non_editable_wrapper(**) - { - tag: :div, - classes: "project-custom-field-non-editable", - aria: { - disabled: true, - label: [ - @project_custom_field.name, - I18n.t(:label_value_x, x: accessible_value_text) - ].join(", ") - }, - tabindex: 0, - ** - } end end end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/items_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/items_component.html.erb index 8c96115183c..6cacaa5de5f 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/items_component.html.erb +++ b/modules/overviews/app/components/overviews/project_custom_fields/items_component.html.erb @@ -5,7 +5,6 @@ render( Overviews::ProjectCustomFields::ItemComponent.new( project_custom_field:, - project_custom_field_values: attribute_load_service.get_eager_loaded_project_custom_field_values_for(project_custom_field.id), project: @project ) ) diff --git a/modules/overviews/app/components/overviews/project_custom_fields/items_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/items_component.rb index 93ca069c423..5183aba83c9 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/items_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/items_component.rb @@ -40,11 +40,6 @@ module Overviews @project_custom_fields = project_custom_fields @project = project end - - def attribute_load_service - @attribute_load_service ||= ::ProjectCustomFields::LoadService.new(project: @project, - project_custom_fields: @project_custom_fields) - end end end end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb index 82ca9deb719..7e30c0381fd 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb +++ b/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb @@ -1,73 +1,6 @@ <%= render OpenProject::Common::InplaceEditFieldComponent.new( - model: @project, - attribute: @project_custom_field.attribute_name.to_sym, - open_in_dialog: limited_space?, - truncated: limited_space? -) %> - - - -<%# end %> + model: @project, + attribute: @project_custom_field.attribute_name.to_sym, + open_in_dialog: limited_space?, + truncated: limited_space? + ) %> diff --git a/modules/overviews/app/components/overviews/project_custom_fields/show_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/show_component.rb index 22c203ca0e4..e31982deb56 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/show_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/show_component.rb @@ -31,139 +31,17 @@ module Overviews module ProjectCustomFields class ShowComponent < ApplicationComponent - include ApplicationHelper - include CalculatedValues::ErrorsHelper - include CustomFieldsHelper - include OpPrimer::ComponentHelpers - - delegate :has_comment?, :calculated_value?, to: :@project_custom_field - - def initialize(project_custom_field:, project_custom_field_values:, project:) + def initialize(project_custom_field:, project:) super @project_custom_field = project_custom_field - @project_custom_field_values = Array(project_custom_field_values) @project = project end private - def show_comment? = @project_custom_field.has_comment? - - def value_wrapper_attributes = {} - - def value_wrapper - Primer::Beta::Text.new(**value_wrapper_attributes) - end - - def required? - @project_custom_field.required? && !@project_custom_field.calculated_value? - end - - def not_set? - @project_custom_field_values.none?(&:value?) - end - - def calculation_error? - @project_custom_field.first_calculation_error(@project).present? - end - - def render_calculation_error - error = @project_custom_field.first_calculation_error(@project) - - render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, - data: { - test_selector: "error-cf-#{@project_custom_field.id}" - })) do |container| - container.with_column do - render Primer::Beta::Octicon.new(icon: :"alert-fill", color: :danger) - end - container.with_column(ml: 2) do - render Primer::Beta::Text.new(color: :danger) do - calculated_value_error_msg(error) - end - end - end - end - - def render_calculated_value_tooltip - render Primer::Alpha::Tooltip.new( - for_id: calculated_value_tooltip_id, - type: :description, - text: I18n.t("custom_fields.calculated_field_not_editable"), - direction: :s - ) - end - - def calculated_value_tooltip_id - calculated_value? ? "calculated-field-tooltip-#{@project_custom_field.id}" : nil - end - - def render_value - case @project_custom_field.field_format - when "link" - render_link - when "text" - render_long_text - when "user" - render_user - else - render_custom_field_values - end - end - - def render_long_text - render OpenProject::Common::AttributeComponent.new("dialog-cf-#{@project_custom_field.id}", - @project_custom_field.name, - @project_custom_field_values.first&.value, - lines: 3) - 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 render_avatar(user) - render(Users::AvatarComponent.new(user:, size: :mini)) - end - - def render_link - href = @project_custom_field_values.first&.value - link = Addressable::URI.parse(href) - return href unless link - - target = link.host == Setting.host_without_protocol ? "_top" : "_blank" - render(Primer::Beta::Link.new(href:, rel: "noopener noreferrer", target:)) do - href - end - end - - def render_custom_field_values - render(Primer::Beta::Text.new) { custom_field_values } - end - - def accessible_value_text - return I18n.t("placeholders.default") if not_set? - - custom_field_values - end - - def custom_field_values - return @custom_field_values if defined?(@custom_field_values) - - values = @project_custom_field_values.map { |v| format_value(v.value, @project_custom_field) } - - @custom_field_values = @project_custom_field.multi_value? ? values.join(", ") : values.first + def limited_space? + false end end end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/show_component.sass b/modules/overviews/app/components/overviews/project_custom_fields/show_component.sass deleted file mode 100644 index 9851bc36c6e..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/show_component.sass +++ /dev/null @@ -1,22 +0,0 @@ -.project-custom-fields-rich-text-preview - :last-child - display: inline - -// Style non-editable fields to match hover-input dimensions -.project-custom-field-non-editable - @include hover-input-base - min-height: 1.2em // Ensure consistent height even when empty - - @media (hover: none) and (pointer: coarse) - background-color: var(--bgColor-disabled) - color: var(--fgColor-disabled) - @media (hover: hover) - &:hover, &:focus-visible - background-color: var(--bgColor-disabled) - color: var(--fgColor-disabled) - -// Style clickable fields -.project-custom-field-clickable - @include hover-input-base - cursor: pointer - min-height: 1.2em diff --git a/modules/overviews/app/components/overviews/project_custom_fields/show_dialog_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/show_dialog_component.rb deleted file mode 100644 index 600a78aeafc..00000000000 --- a/modules/overviews/app/components/overviews/project_custom_fields/show_dialog_component.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Overviews - module ProjectCustomFields - class ShowDialogComponent < DialogComponent - private - - def body_component - Overviews::ProjectCustomFields::ShowComponent.new( - project_custom_field: @project_custom_field, - project_custom_field_values: @project.custom_values_for_custom_field(@project_custom_field), - project: @project - ) - end - - def close_button_title - t("button_close") - end - end - end -end diff --git a/modules/overviews/app/controllers/overviews/project_custom_fields_controller.rb b/modules/overviews/app/controllers/overviews/project_custom_fields_controller.rb deleted file mode 100644 index 3b9c7cd9ebc..00000000000 --- a/modules/overviews/app/controllers/overviews/project_custom_fields_controller.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -class Overviews::ProjectCustomFieldsController < ApplicationController - include OpTurbo::ComponentStream - - before_action :find_project_by_project_id - before_action :find_project_custom_field - before_action :authorize - - def show - respond_with_dialog( - Overviews::ProjectCustomFields::ShowDialogComponent.new( - project: @project, - project_custom_field: @custom_field - ) - ) - end - - def edit - respond_with_dialog( - Overviews::ProjectCustomFields::EditDialogComponent.new( - project: @project, - project_custom_field: @custom_field - ) - ) - end - - def update - # FIXME: submitted format of form parameters are not configurable for the tree view component. Hence, we - # need to process it before giving them in standard format to the update service. - if @custom_field.hierarchical_list? - process_hierarchy_params - end - - service_call = ::Projects::UpdateService - .new( - user: current_user, - model: @project, - contract_options: { project_attributes_only: true } - ) - .call(permitted_params.project) - - if service_call.success? - if field_shown_in_sidebar?(@custom_field) - update_sidebar_component - else - update_widgets_component - end - else - handle_errors(service_call.result, @custom_field) - end - - respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) - end - - private - - def process_hierarchy_params - values = params.dig(:project, :custom_field_values) - - ids = Array(values).reject(&:empty?).map do |value| - MultiJson.load(value, symbolize_keys: true)[:value] - end - - params[:project][:custom_field_values] = { @custom_field.id.to_s => ids.one? ? ids.first : ids } - end - - def find_project_custom_field - @custom_field = @project.available_custom_fields.find(params[:id]) - end - - def handle_errors(project_with_errors, custom_field) - update_via_turbo_stream( - component: Overviews::ProjectCustomFields::EditComponent.new( - project: project_with_errors, - project_custom_field: custom_field - ) - ) - end - - def update_sidebar_component - update_via_turbo_stream( - component: Overviews::ProjectCustomFields::SidePanelComponent.new(project: @project) - ) - end - - def update_widgets_component - update_via_turbo_stream( - component: Grids::ProjectAttributeWidgets.new(@project) - ) - end - - def field_shown_in_sidebar?(custom_field) - CustomFieldSection.find(custom_field.custom_field_section_id).shown_in_overview_sidebar? - end -end diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index 0f052e803f8..1cb354e5a06 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -12,8 +12,6 @@ Rails.application.routes.draw do get "project_custom_fields_sidebar" => :project_custom_fields_sidebar, as: :custom_fields_sidebar get "project_life_cycle_sidebar" => :project_life_cycle_sidebar, as: :life_cycle_sidebar end - - resources :project_custom_fields, only: %i[show edit update], as: :custom_fields end end end diff --git a/modules/overviews/spec/routing/project_custom_fields_routing_spec.rb b/modules/overviews/spec/routing/project_custom_fields_routing_spec.rb deleted file mode 100644 index ff57d4a04a2..00000000000 --- a/modules/overviews/spec/routing/project_custom_fields_routing_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "rails_helper" - -RSpec.describe Overviews::ProjectCustomFieldsController do - describe "routing" do - describe "#show" do - it do - expect(get("/projects/my-project/project_custom_fields/33")) - .to route_to( - controller: "overviews/project_custom_fields", - action: "show", - project_id: "my-project", - id: "33" - ) - end - end - - describe "#edit" do - it do - expect(get("/projects/my-project/project_custom_fields/33/edit")) - .to route_to( - controller: "overviews/project_custom_fields", - action: "edit", - project_id: "my-project", - id: "33" - ) - end - end - - describe "PUT/PATCH #update" do - it do - expect(put("/projects/my-project/project_custom_fields/44")) - .to route_to( - controller: "overviews/project_custom_fields", - action: "update", - project_id: "my-project", - id: "44" - ) - end - - it do - expect(patch("/projects/my-project/project_custom_fields/44")) - .to route_to( - controller: "overviews/project_custom_fields", - action: "update", - project_id: "my-project", - id: "44" - ) - end - end - end - - describe "named routing" do - describe "GET #edit" do - it do - expect(get(edit_project_custom_field_path("my-project", 33))) - .to route_to( - controller: "overviews/project_custom_fields", - action: "edit", - project_id: "my-project", - id: "33" - ) - end - end - - describe "PUT/PATCH #update" do - it do - expect(put(project_custom_field_path("my-project", 44))) - .to route_to( - controller: "overviews/project_custom_fields", - action: "update", - project_id: "my-project", - id: "44" - ) - end - - it do - expect(patch("/projects/my-project/project_custom_fields/44")) - .to route_to( - controller: "overviews/project_custom_fields", - action: "update", - project_id: "my-project", - id: "44" - ) - end - end - end -end From 53056b34f68243d97bc82dbc4efcf6d0298285ae Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 12 Mar 2026 17:59:52 +0100 Subject: [PATCH 108/435] display sprint on wp view --- .../display/display-field.initializer.ts | 1 + .../lib/open_project/backlogs/engine.rb | 8 +++- .../patches/api/work_package_representer.rb | 8 ++-- .../api/work_package_schema_representer.rb | 3 +- .../work_packages/sprints_on_wp_view_spec.rb | 48 +++++++++++++++++++ .../work_package_schema_representer_spec.rb | 48 +++++++++++++++++-- ...work_package_representer_rendering_spec.rb | 36 ++++++++++++-- 7 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb diff --git a/frontend/src/app/shared/components/fields/display/display-field.initializer.ts b/frontend/src/app/shared/components/fields/display/display-field.initializer.ts index c33d0b26de1..f8d092aa5bd 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.initializer.ts @@ -107,6 +107,7 @@ export function initializeCoreDisplayFields(displayFieldService:DisplayFieldServ 'TimeEntriesActivity', 'Version', 'Category', + 'Sprint', 'CustomField::Hierarchy::Item', 'CustomOption']) .addFieldType(ProjectPhaseDisplayField, 'projectPhase', ['ProjectPhase']) diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 7033ed108dc..b30ae5c2ee9 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -211,14 +211,16 @@ module OpenProject::Backlogs config.to_prepare do enabled_backlogs_story = ->(type, project: nil) do if project.present? - project.backlogs_enabled? && type.story? + project.backlogs_enabled? && (type.story? || OpenProject::FeatureDecisions.scrum_projects_active?) else # Allow globally configuring the attribute if story - type.story? + type.story? || OpenProject::FeatureDecisions.scrum_projects_active? end end story_and_sprint_permission = ->(type, project: nil) do + return true if OpenProject::FeatureDecisions.scrum_projects_active? + if project.present? type.story? && User.current.allowed_in_project?(:view_sprints, project) else @@ -227,12 +229,14 @@ module OpenProject::Backlogs end end + # TODO: upon removal of the scrum_projects feature flag, remove these constraints ::Type.add_constraint :position, enabled_backlogs_story ::Type.add_constraint :story_points, enabled_backlogs_story ::Type.add_constraint :sprint, story_and_sprint_permission ::Type.add_default_mapping(:estimates_and_progress, :story_points) ::Type.add_default_mapping(:other, :position) + ::Type.add_default_mapping(:details, :sprint) ::Queries::Register.register(::Query) do filter OpenProject::Backlogs::WorkPackageFilter diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb index 36adf0f38ce..f6040e6769a 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb @@ -37,13 +37,13 @@ module OpenProject::Backlogs property :position, render_nil: true, skip_render: ->(*) do - !(backlogs_enabled? && type&.passes_attribute_constraint?(:position)) + !(backlogs_enabled? && type&.passes_attribute_constraint?(:position, project:)) end property :story_points, render_nil: true, skip_render: ->(*) do - !(backlogs_enabled? && type&.passes_attribute_constraint?(:story_points)) + !(backlogs_enabled? && type&.passes_attribute_constraint?(:story_points, project:)) end resource :sprint, @@ -52,7 +52,7 @@ module OpenProject::Backlogs OpenProject::FeatureDecisions.scrum_projects_active? }, link: ->(*) { - next unless represented.type&.passes_attribute_constraint?(:sprint) + next unless represented.type&.passes_attribute_constraint?(:sprint, project: represented.project) if represented.sprint.present? { @@ -68,7 +68,7 @@ module OpenProject::Backlogs getter: ->(*) do if embed_links && represented.sprint.present? && - represented.type&.passes_attribute_constraint?(:story_points) && + represented.type&.passes_attribute_constraint?(:story_points, project: represented.project) && current_user.allowed_in_project?(:view_sprints, represented.project) && OpenProject::FeatureDecisions.scrum_projects_active? ::API::V3::Sprints::SprintRepresenter.create(represented.sprint, current_user:) diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb index 91f6cabd3b2..66f70df1d63 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_schema_representer.rb @@ -53,7 +53,8 @@ module OpenProject::Backlogs has_default: false, required: false, show_if: ->(*) { - backlogs_constraint_passed?(:sprint) && + current_user.allowed_in_project?(:view_sprints, represented.project) && + backlogs_constraint_passed?(:sprint) && OpenProject::FeatureDecisions.scrum_projects_active? }, href_callback: ->(*) { diff --git a/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb b/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb new file mode 100644 index 00000000000..9c4770252bb --- /dev/null +++ b/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" + +RSpec.describe "Sprint displayed and selectable on work package view", :js, with_flag: { scrum_projects: true } do + shared_let(:project) { create(:project) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:other_sprint) { create(:agile_sprint, project:) } + shared_let(:work_package) { create(:work_package, project:, sprint:) } + + current_user { create(:user, member_with_permissions: { project => %i(view_work_packages view_sprints manage_sprint_items) }) } + + let(:wp_page) { Pages::FullWorkPackage.new(work_package) } + + it "shows sprints and allows changing them" do + wp_page.visit! + + wp_page.expect_attributes sprint: sprint.name + end +end diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index 308dabf4340..360f9989ecd 100644 --- a/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -77,7 +77,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "when not a story" do + context "when not a story with the feature flag inactive", with_flag: { scrum_projects: false } do before do allow(schema.type).to receive(:story?).and_return(false) end @@ -86,6 +86,20 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with expect(subject).not_to have_json_path("storyPoints") end end + + context "when not a story with the feature flag active" do + before do + allow(schema.type).to receive(:story?).and_return(false) + end + + it_behaves_like "has basic schema properties" do + let(:path) { "storyPoints" } + let(:type) { "Integer" } + let(:name) { I18n.t("activerecord.attributes.work_package.story_points") } + let(:required) { false } + let(:writable) { true } + end + end end describe "position" do @@ -107,7 +121,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "when not a story" do + context "when not a story with the feature flag inactive", with_flag: { scrum_projects: false } do before do allow(schema.type).to receive(:story?).and_return(false) end @@ -116,6 +130,20 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with expect(subject).not_to have_json_path("position") end end + + context "when not a story with the feature flag active" do + before do + allow(schema.type).to receive(:story?).and_return(false) + end + + it_behaves_like "has basic schema properties" do + let(:path) { "position" } + let(:type) { "Integer" } + let(:name) { I18n.t("activerecord.attributes.work_package.position") } + let(:required) { false } + let(:writable) { false } + end + end end describe "sprint" do @@ -159,7 +187,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "when not a story" do + context "when not a story with the feature flag inactive", with_flag: { scrum_projects: false } do before do allow(schema.type).to receive(:story?).and_return(false) end @@ -168,5 +196,19 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with expect(subject).not_to have_json_path("sprint") end end + + context "when not a story with the feature flag active" do + before do + allow(schema.type).to receive(:story?).and_return(false) + end + + it_behaves_like "has basic schema properties" do + let(:type) { "Sprint" } + let(:name) { I18n.t("activerecord.attributes.work_package.sprint") } + let(:required) { false } + let(:writable) { true } + let(:location) { "_links" } + end + end end end diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb index e5a5ca5197c..ccd82e08239 100644 --- a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb @@ -85,11 +85,19 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ end end - context "when it is a task" do + context "when it is a task with the feature flag off", with_flag: { scrum_projects: false } do let(:type) { task_type } it_behaves_like "no property", :storyPoints end + + context "when it is a task with the feature flag on" do + let(:type) { task_type } + + it_behaves_like "property", :storyPoints do + let(:value) { story_points } + end + end end describe "position" do @@ -97,11 +105,19 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ let(:value) { position } end - context "when it is a task" do + context "when it is a task with the feature flag off", with_flag: { scrum_projects: false } do let(:type) { task_type } it_behaves_like "no property", :position end + + context "when it is a task with the feature flag on" do + let(:type) { task_type } + + it_behaves_like "property", :position do + let(:value) { position } + end + end end end @@ -125,11 +141,17 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ it_behaves_like "has no link" end - context "when it is a task" do + context "when it is a task with the feature flag off", with_flag: { scrum_projects: false } do let(:type) { task_type } it_behaves_like "has no link" end + + context "when it is a task with the feature flag on" do + let(:type) { task_type } + + it_behaves_like "has a titled link" + end end describe "update links" do @@ -169,11 +191,17 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ it_behaves_like "has the resource not embedded" end - context "when it is a type" do + context "when it is a type with the feature flag off", with_flag: { scrum_projects: false } do let(:type) { task_type } it_behaves_like "has the resource not embedded" end + + context "when it is a type with the feature flag on" do + let(:type) { task_type } + + it_behaves_like "has the resource embedded" + end end end end From de02fd315c685ca621044d44e7856d74e09da613 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 12 Mar 2026 18:46:50 +0100 Subject: [PATCH 109/435] edit sprint on wp view --- .../components/fields/edit/edit-field.initializer.ts | 1 + .../features/work_packages/sprints_on_wp_view_spec.rb | 8 ++++++++ spec/support/edit_fields/edit_field.rb | 2 ++ 3 files changed, 11 insertions(+) diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts index edf309e889d..118b58bdb88 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts @@ -100,6 +100,7 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele 'Version', 'TimeEntriesActivity', 'Category', + 'Sprint', 'CustomOption', 'CustomField::Hierarchy::Item', ]) diff --git a/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb b/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb index 9c4770252bb..5e1bd9088e1 100644 --- a/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb +++ b/modules/backlogs/spec/features/work_packages/sprints_on_wp_view_spec.rb @@ -44,5 +44,13 @@ RSpec.describe "Sprint displayed and selectable on work package view", :js, with wp_page.visit! wp_page.expect_attributes sprint: sprint.name + + wp_page.set_attributes({ sprint: other_sprint.name }) + wp_page.expect_and_dismiss_toaster message: I18n.t(:notice_successful_update) + + # Ensure the sprint association is persisted + wp_page.visit! + + wp_page.expect_attributes sprint: other_sprint.name end end diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index e14550dc0d1..18b925ca61e 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -315,6 +315,8 @@ class EditField "op-project-autocompleter" when :activity "activity-autocompleter" + when :sprint + "sprint-autocompleter" else "input" end From e04ee024621cf012875565ec4916e664edeebc59 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 20:36:07 +0100 Subject: [PATCH 110/435] [#71896] Add support for semantic IDs into Change Project Identifier --- ...hange_identifier_dialog_component.html.erb | 71 +++++++++++++++++++ .../change_identifier_dialog_component.rb | 45 ++++++++++++ .../settings/general/show_component.html.erb | 21 ++++++ .../index_page_header_component.html.erb | 24 ++++--- .../projects/identifier_controller.rb | 2 + app/controllers/projects_controller.rb | 10 ++- .../settings/editable_identifier_form.rb | 62 ++++++++++++++++ .../projects/settings/identifier_form.rb | 46 ++++++++++++ app/models/project.rb | 14 +++- app/views/projects/identifier/show.html.erb | 12 +++- config/initializers/permissions.rb | 2 +- config/locales/en.yml | 11 +++ config/routes.rb | 3 + .../settings/general/show_component_spec.rb | 11 +++ spec/features/projects/edit_settings_spec.rb | 34 ++++----- 15 files changed, 331 insertions(+), 37 deletions(-) create mode 100644 app/components/projects/settings/change_identifier_dialog_component.html.erb create mode 100644 app/components/projects/settings/change_identifier_dialog_component.rb create mode 100644 app/forms/projects/settings/editable_identifier_form.rb create mode 100644 app/forms/projects/settings/identifier_form.rb diff --git a/app/components/projects/settings/change_identifier_dialog_component.html.erb b/app/components/projects/settings/change_identifier_dialog_component.html.erb new file mode 100644 index 00000000000..ea86981031b --- /dev/null +++ b/app/components/projects/settings/change_identifier_dialog_component.html.erb @@ -0,0 +1,71 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render(Primer::Alpha::Dialog.new( + id: "change-identifier-dialog", + title: t("projects.settings.change_identifier_dialog_title") + )) do |dialog| %> + <% dialog.with_body do %> + <%= render(Primer::Alpha::Banner.new(scheme: :warning, mb: 3)) do %> + <%= t("projects.settings.change_identifier_warning") %> + <% end %> + + <%= form_with model: project, + url: project_identifier_path(project), + id: "change-identifier-form", + class: "mt-3" do |f| %> + <% if Project.semantic_alphanumeric_identifier? %> + <%= f.text_field :identifier, + class: "FormControl-input width-full", + pattern: "[A-Z][A-Z0-9_]*", + maxlength: 10, + required: true, + title: t(:text_project_identifier_handle_format) %> +

<%= t("projects.settings.change_identifier_format_hint_semantic") %>

+ <% else %> + <%= f.text_field :identifier, + class: "FormControl-input width-full", + pattern: "[a-z][a-z0-9\\-_]*", + maxlength: 100, + required: true, + title: t(:text_project_identifier_format) %> +

<%= t("projects.settings.change_identifier_format_hint_legacy") %>

+ <% end %> + <% end %> + <% end %> + + <% dialog.with_footer do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": "change-identifier-dialog" })) do %> + <%= t(:button_cancel) %> + <% end %> + <%= render(Primer::Beta::Button.new(scheme: :primary, type: :submit, form: "change-identifier-form")) do %> + <%= t("projects.settings.change_identifier") %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/projects/settings/change_identifier_dialog_component.rb b/app/components/projects/settings/change_identifier_dialog_component.rb new file mode 100644 index 00000000000..fb3e97da957 --- /dev/null +++ b/app/components/projects/settings/change_identifier_dialog_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects + module Settings + class ChangeIdentifierDialogComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + attr_reader :project + + def initialize(project:) + super + @project = project + end + end + end +end diff --git a/app/components/projects/settings/general/show_component.html.erb b/app/components/projects/settings/general/show_component.html.erb index 89cd46de25e..0d8db5c69c6 100644 --- a/app/components/projects/settings/general/show_component.html.erb +++ b/app/components/projects/settings/general/show_component.html.erb @@ -46,6 +46,27 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> +<% if OpenProject::FeatureDecisions.semantic_work_package_ids_active? %> + <%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %> + <%= + render(Primer::Beta::Subhead.new) do |component| + component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) } + end + %> + <%= + settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f| + render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f))) + end + %> + <%= render(Primer::Beta::Button.new( + tag: :a, + href: projects_identifier_dialog_path(project_id: project), + data: { turbo_stream: true } + )) { t("projects.settings.change_identifier") } %> + <% end %> +<% end %> + + <%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %> <%= render(Primer::Beta::Subhead.new) do |component| diff --git a/app/components/projects/settings/index_page_header_component.html.erb b/app/components/projects/settings/index_page_header_component.html.erb index 0c3f591f590..08aed6f0fb2 100644 --- a/app/components/projects/settings/index_page_header_component.html.erb +++ b/app/components/projects/settings/index_page_header_component.html.erb @@ -24,17 +24,19 @@ end end - header.with_action_button( - tag: :a, - mobile_icon: :pencil, - mobile_label: t("projects.settings.change_identifier"), - size: :medium, - href: project_identifier_path(@project), - aria: { label: t("projects.settings.change_identifier") }, - title: t("projects.settings.change_identifier") - ) do |button| - button.with_leading_visual_icon(icon: :pencil) - t("projects.settings.change_identifier") + unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? + header.with_action_button( + tag: :a, + mobile_icon: :pencil, + mobile_label: t("projects.settings.change_identifier"), + size: :medium, + href: project_identifier_path(@project), + aria: { label: t("projects.settings.change_identifier") }, + title: t("projects.settings.change_identifier") + ) do |button| + button.with_leading_visual_icon(icon: :pencil) + t("projects.settings.change_identifier") + end end header.with_action_menu( diff --git a/app/controllers/projects/identifier_controller.rb b/app/controllers/projects/identifier_controller.rb index f89d842d797..fd2b4376f1d 100644 --- a/app/controllers/projects/identifier_controller.rb +++ b/app/controllers/projects/identifier_controller.rb @@ -29,6 +29,8 @@ #++ class Projects::IdentifierController < ApplicationController + include OpTurbo::ComponentStream + before_action :find_project_by_project_id before_action :authorize diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 124afd6d046..021b796467e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -34,7 +34,8 @@ class ProjectsController < ApplicationController menu_item :overview menu_item :roadmap, only: :roadmap - before_action :find_project, except: %i[index new create destroy destroy_info] + before_action :find_project, except: %i[index new create destroy destroy_info identifier_dialog] + before_action :find_project_by_project_id, only: %i[identifier_dialog] before_action :find_project_including_archived, only: %i[destroy destroy_info] before_action :load_query_or_deny_access, only: %i[index] before_action :authorize, @@ -45,6 +46,7 @@ class ProjectsController < ApplicationController before_action :find_optional_template, only: %i[new create] no_authorization_required! :index + no_authorization_required! :identifier_dialog include SortHelper include PaginationHelper @@ -161,6 +163,12 @@ class ProjectsController < ApplicationController respond_with_dialog Projects::DeleteDialogComponent.new(project: @project) end + def identifier_dialog + return render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? + + respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project) + end + def deactivate_work_package_attachments call = Projects::UpdateService .new(user: current_user, model: @project, contract_class: Projects::SettingsContract) diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb new file mode 100644 index 00000000000..c08053ebd28 --- /dev/null +++ b/app/forms/projects/settings/editable_identifier_form.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Projects + module Settings + class EditableIdentifierForm < ApplicationForm + form do |f| + if Project.semantic_alphanumeric_identifier? + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), + required: true, + maxlength: 10, + validation_message: validation_message_for(:identifier) + ) + else + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), + required: true, + maxlength: Project::IDENTIFIER_MAX_LENGTH, + validation_message: validation_message_for(:identifier) + ) + end + end + + private + + def validation_message_for(attribute) + model.errors.messages_for(attribute).to_sentence.presence + end + end + end +end diff --git a/app/forms/projects/settings/identifier_form.rb b/app/forms/projects/settings/identifier_form.rb new file mode 100644 index 00000000000..95f70fc38a9 --- /dev/null +++ b/app/forms/projects/settings/identifier_form.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Projects + module Settings + class IdentifierForm < ApplicationForm + form do |f| + caption_key = Project.semantic_alphanumeric_identifier? ? + :text_project_identifier_description : + :text_project_identifier_url_description + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t(caption_key), + disabled: true + ) + end + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 41fb0312f92..2e12211434c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -47,7 +47,7 @@ class Project < ApplicationRecord IDENTIFIER_MAX_LENGTH = 100 # reserved identifiers - RESERVED_IDENTIFIERS = %w[new menu queries filters].freeze + RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog].freeze enum :workspace_type, { project: "project", @@ -210,7 +210,13 @@ class Project < ApplicationRecord # Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id. validates :identifier, format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ }, - if: ->(p) { p.identifier_changed? && p.identifier.present? } + if: ->(p) { p.identifier_changed? && p.identifier.present? && !Project.semantic_alphanumeric_identifier? } + + # When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules. + validates :identifier, + format: { with: /\A[A-Z][A-Z0-9_]*\z/ }, + length: { maximum: 10 }, + if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? } validates_associated :repository, :wiki @@ -266,6 +272,10 @@ class Project < ApplicationRecord User.current.allowed_in_project?(:copy_projects, self) end + def self.semantic_alphanumeric_identifier? + OpenProject::FeatureDecisions.semantic_work_package_ids_active? && Setting::WorkPackageIdentifier.alphanumeric? + end + def self.selectable_projects Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s) end diff --git a/app/views/projects/identifier/show.html.erb b/app/views/projects/identifier/show.html.erb index a0209aa6c83..c67a155fec7 100644 --- a/app/views/projects/identifier/show.html.erb +++ b/app/views/projects/identifier/show.html.erb @@ -58,7 +58,17 @@ See COPYRIGHT and LICENSE files for more details. <%= styled_label_tag "identifier", Project.human_attribute_name(:identifier), class: "-required" %>
- <%= f.text_field :identifier %> + <% if Project.semantic_alphanumeric_identifier? %> + <%= f.text_field :identifier, + pattern: "[A-Z][A-Z0-9_]*", + maxlength: 10, + title: t(:text_project_identifier_handle_format) %> + <% else %> + <%= f.text_field :identifier, + pattern: "[a-z][a-z0-9\\-_]*", + maxlength: 100, + title: t(:text_project_identifier_format) %> + <% end %> <%= f.submit t(:button_update), class: "button -primary -with-icon icon-checkmark" %> diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 777e2247c47..528298b7196 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -137,7 +137,7 @@ Rails.application.reloader.to_prepare do "projects/settings/subitems": %i[show update], "projects/settings/template": %i[show update toggle_template], "projects/templated": %i[create destroy], - "projects/identifier": %i[show update], + "projects/identifier": %i[show update update_identifier_dialog], "projects/status": %i[update destroy] }, permissible_on: :project, diff --git a/config/locales/en.yml b/config/locales/en.yml index c023f0ef2da..736823fa5d2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -836,6 +836,12 @@ en: The project will only be visible to project members depending on their role and associated permissions. Sub-projects are not affected and have their own settings. change_identifier: Change identifier + change_identifier_dialog_title: Change project identifier + change_identifier_format_hint_semantic: "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. The first character has to be a letter." + change_identifier_format_hint_legacy: "Only lowercase letters (a–z), numbers, dashes or underscores." + change_identifier_warning: > + This will permanently change identifiers and URLs of all work packages in this project. + The previous identifier and URLs will nevertheless continue to redirect properly. subitems: template_section: > Select templates to be used when creating new subitems. @@ -3711,6 +3717,7 @@ en: label_subject_or_id: "Subject or ID" label_calendar_subscriptions: "Calendar subscriptions" label_identifier: "Identifier" + label_project_identifier: "Project identifier" label_in: "in" label_in_less_than: "in less than" label_in_more_than: "in more than" @@ -5365,6 +5372,10 @@ en: text_plugin_assets_writable: "Plugin assets directory writable" text_powered_by: "Powered by %{link}" text_project_identifier_info: "Only lower case letters (a-z), numbers, dashes and underscores are allowed, must start with a lower case letter." + text_project_identifier_description: "The project identifier is prepended to all work package IDs. If the identifier is \"PROJ\" for example, the work package identifier will be \"PROJ-12\" or \"PROJ-246\"." + text_project_identifier_url_description: "The project identifier is included in the URL of the project." + text_project_identifier_handle_format: "Must start with a letter and contain only uppercase letters, numbers, and underscores (max 10 characters)." + text_project_identifier_format: "Must start with a lowercase letter. Only lowercase letters (a-z), numbers, dashes and underscores are allowed." text_reassign: "Reassign to work package:" text_regexp_multiline: 'The regex is applied in a multi-line mode. e.g., ^---\s+' text_repository_usernames_mapping: "Select or update the OpenProject user mapped to each username found in the repository log.\nUsers with the same OpenProject and repository username or email are automatically mapped." diff --git a/config/routes.rb b/config/routes.rb index 71996e5fc14..b9d0e567674 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -283,6 +283,9 @@ Rails.application.routes.draw do namespace :projects do resource :menu, only: %i[show] resource :filters, only: %i[show] + get "identifier_dialog", to: "/projects#identifier_dialog", + as: :identifier_dialog, + defaults: { format: :turbo_stream } end %w[portfolio project program].each do |workspace_type| diff --git a/spec/components/projects/settings/general/show_component_spec.rb b/spec/components/projects/settings/general/show_component_spec.rb index ce6a8cbb6b2..d0756303ce0 100644 --- a/spec/components/projects/settings/general/show_component_spec.rb +++ b/spec/components/projects/settings/general/show_component_spec.rb @@ -76,6 +76,17 @@ RSpec.describe Projects::Settings::General::ShowComponent, type: :component do end end + describe "Identifier" do + before { with_flags(semantic_work_package_ids: true) } + + it_behaves_like "section with heading", "Identifier" + + it "renders a Change identifier button" do + render_component + expect(page.find(:section, "Identifier")).to have_link "Change identifier" + end + end + describe "Project relations" do it_behaves_like "section with heading", "Project relations" diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index c9599f9cee1..336e4f7f30b 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -52,32 +52,24 @@ RSpec.describe "Projects", "editing settings", :js do end describe "identifier edit" do - it "updates the project identifier" do - visit projects_path - click_on project.name - click_on "Project settings" + before { with_flags(semantic_work_package_ids: true) } + + it "updates the project identifier via dialog" do + visit project_settings_general_path(project) + 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_dialog "Change project identifier" - fill_in "project[identifier]", with: "foo-bar" - click_on "Update" + within "dialog" do + expect(page).to have_text "This will permanently change identifiers and URLs" + fill_in "project[identifier]", with: "foo-bar" + click_on "Change identifier" + end 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" - end - - it "displays error messages on invalid input" do - visit project_identifier_path(project) - - 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_current_path %r{/projects/foo-bar/settings/general} + expect(project.reload.identifier).to eq "foo-bar" end end From ad8738779ef7b2c8a9f222f4a0c90fc1c1dcf4d9 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 21:44:24 +0100 Subject: [PATCH 111/435] squash a rubocop complaint --- app/forms/projects/settings/identifier_form.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/forms/projects/settings/identifier_form.rb b/app/forms/projects/settings/identifier_form.rb index 95f70fc38a9..de257e820d1 100644 --- a/app/forms/projects/settings/identifier_form.rb +++ b/app/forms/projects/settings/identifier_form.rb @@ -31,9 +31,11 @@ module Projects module Settings class IdentifierForm < ApplicationForm form do |f| - caption_key = Project.semantic_alphanumeric_identifier? ? - :text_project_identifier_description : + caption_key = if Project.semantic_alphanumeric_identifier? + :text_project_identifier_description + else :text_project_identifier_url_description + end f.text_field( name: :identifier, label: attribute_name(:identifier), From a30973cdc76c973bf77f5bab0dd354a98c052148 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 21:51:01 +0100 Subject: [PATCH 112/435] [#72855] Add semantic identifier support to "Add Project" --- .../projects/copy_form_component.rb | 13 ++ .../projects/new_component.html.erb | 3 +- app/components/projects/new_component.rb | 13 ++ .../identifier_suggestions_controller.rb | 48 +++++++ app/models/project.rb | 2 +- .../project_handle_suggestion_generator.rb | 8 ++ config/locales/js-en.yml | 4 + config/routes.rb | 2 + .../identifier-suggestion.controller.ts | 133 ++++++++++++++++++ frontend/src/stimulus/setup.ts | 2 + .../settings/editable_identifier_form_spec.rb | 75 ++++++++++ spec/models/permitted_params_spec.rb | 6 + .../projects/identifier_suggestions_spec.rb | 68 +++++++++ ...roject_handle_suggestion_generator_spec.rb | 10 ++ 14 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 app/controllers/projects/identifier_suggestions_controller.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts create mode 100644 spec/forms/projects/settings/editable_identifier_form_spec.rb create mode 100644 spec/requests/projects/identifier_suggestions_spec.rb diff --git a/app/components/projects/copy_form_component.rb b/app/components/projects/copy_form_component.rb index 9d1cefd0d77..2c9492f8475 100644 --- a/app/components/projects/copy_form_component.rb +++ b/app/components/projects/copy_form_component.rb @@ -35,5 +35,18 @@ module Projects include OpTurbo::Streamable options :source_project, :target_project, :copy_options + + def identifier_suggestion_data + data = { + controller: "projects--identifier-suggestion", + "projects--identifier-suggestion-mode-value": semantic_identifier? ? "semantic" : "legacy" + } + data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if semantic_identifier? + data + end + + def semantic_identifier? + Project.semantic_alphanumeric_identifier? + end end end diff --git a/app/components/projects/new_component.html.erb b/app/components/projects/new_component.html.erb index 081e6e35a97..af5304489ba 100644 --- a/app/components/projects/new_component.html.erb +++ b/app/components/projects/new_component.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - component_wrapper(target: "_top") do + component_wrapper(target: "_top", data: identifier_suggestion_data) do settings_primer_form_with( model: project, url: workspaces_path, @@ -39,6 +39,7 @@ See COPYRIGHT and LICENSE files for more details. render( Primer::Forms::FormList.new( Projects::Settings::NameForm.new(f), + Projects::Settings::EditableIdentifierForm.new(f), Projects::Settings::DescriptionForm.new(f), Projects::Settings::RelationsForm.new(f, invisible: params[:parent_id].present?), Projects::Settings::TypeForm.new(f) diff --git a/app/components/projects/new_component.rb b/app/components/projects/new_component.rb index 977992a2524..5cac948a7f5 100644 --- a/app/components/projects/new_component.rb +++ b/app/components/projects/new_component.rb @@ -44,6 +44,19 @@ module Projects { display: :none } unless step == 3 end + def identifier_suggestion_data + data = { + controller: "projects--identifier-suggestion", + "projects--identifier-suggestion-mode-value": semantic_identifier? ? "semantic" : "legacy" + } + data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if semantic_identifier? + data + end + + def semantic_identifier? + Project.semantic_alphanumeric_identifier? + end + def workspaces_path workspace_type = if Project.workspace_types.key?(project.workspace_type) project.workspace_type diff --git a/app/controllers/projects/identifier_suggestions_controller.rb b/app/controllers/projects/identifier_suggestions_controller.rb new file mode 100644 index 00000000000..6c6c2702a9e --- /dev/null +++ b/app/controllers/projects/identifier_suggestions_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects + class IdentifierSuggestionsController < ApplicationController + before_action :require_login + no_authorization_required! :show + + def show + unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? + render json: {}, status: :not_found and return + end + + name = params[:name].to_s.strip + return render json: {}, status: :unprocessable_entity if name.blank? + + identifier = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.suggest_for_name(name) + render json: { identifier: } + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 2e12211434c..6f89b23c1c7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -47,7 +47,7 @@ class Project < ApplicationRecord IDENTIFIER_MAX_LENGTH = 100 # reserved identifiers - RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog].freeze + RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog identifier_suggestion].freeze enum :workspace_type, { project: "project", diff --git a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb index 6f60553379f..02833d8c8dc 100644 --- a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb @@ -54,6 +54,14 @@ module WorkPackages new.call(projects, reserved_handles:, in_use_handles:) end + def self.suggest_for_name(name) + new.suggest_for_name(name) + end + + def suggest_for_name(name) + handle_from_name(name) + end + def call(projects, reserved_handles:, in_use_handles:) generate_suggestions(projects, reserved_handles:, in_use_handles:) end diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index b4fa4cc290d..084a63c3edd 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1298,3 +1298,7 @@ en: waiting_subtitle: network_off: "There is a network problem." network_on: "Network is back. We are trying." + projects: + identifier_suggestion: + loading: "Loading suggestion..." + set_name_first: "Please set the name first." diff --git a/config/routes.rb b/config/routes.rb index b9d0e567674..9e3ee67d939 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -288,6 +288,8 @@ Rails.application.routes.draw do defaults: { format: :turbo_stream } end + get "projects/identifier_suggestion", to: "projects/identifier_suggestions#show", as: :projects_identifier_suggestion + %w[portfolio project program].each do |workspace_type| resources workspace_type.pluralize, only: %i[new create], diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts new file mode 100644 index 00000000000..127f5111c0b --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -0,0 +1,133 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import {Controller} from '@hotwired/stimulus'; +import {debounce, DebouncedFunc} from 'lodash'; + +const ALLOWED_CHARS:Record = { + semantic: /[^A-Z0-9_]/g, + legacy: /[^a-z0-9\-_]/g, +}; + +export default class extends Controller { + static values = { + url: String, + debounce: {type: Number, default: 300}, + mode: {type: String, default: 'legacy'}, + }; + + declare urlValue:string; + declare debounceValue:number; + declare modeValue:string; + + private nameInput:HTMLInputElement | null = null; + private identifierInput:HTMLInputElement | null = null; + private debouncedSuggest:DebouncedFunc<(name:string) => Promise> | null = null; + private handleBlur:((event:Event) => void) | null = null; + private handleInput:((event:Event) => void) | null = null; + + connect():void { + this.nameInput = this.element.querySelector('[name="project[name]"]'); + this.identifierInput = this.element.querySelector('[name="project[identifier]"]'); + + if (!this.nameInput || !this.identifierInput) return; + + this.handleInput = () => this.filterInput(); + this.identifierInput.addEventListener('input', this.handleInput); + + if (this.urlValue) { + this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.set_name_first'); + this.identifierInput.readOnly = true; + + this.debouncedSuggest = debounce( + (name:string) => this.fetchSuggestion(name), + this.debounceValue, + ); + + this.handleBlur = () => { + const name = this.nameInput!.value.trim(); + if (name) void this.debouncedSuggest!(name); + }; + + this.nameInput.addEventListener('blur', this.handleBlur); + } + } + + disconnect():void { + this.debouncedSuggest?.cancel(); + if (this.nameInput && this.handleBlur) { + this.nameInput.removeEventListener('blur', this.handleBlur); + } + if (this.identifierInput && this.handleInput) { + this.identifierInput.removeEventListener('input', this.handleInput); + } + } + + private filterInput():void { + if (!this.identifierInput) return; + + const pattern = ALLOWED_CHARS[this.modeValue] ?? ALLOWED_CHARS.legacy; + const current = this.identifierInput.value; + const filtered = current.replace(pattern, ''); + + if (filtered !== current) { + const pos = this.identifierInput.selectionStart ?? filtered.length; + this.identifierInput.value = filtered; + const newPos = Math.min(pos, filtered.length); + this.identifierInput.setSelectionRange(newPos, newPos); + } + } + + private async fetchSuggestion(name:string):Promise { + if (!this.urlValue) return; + + if (this.identifierInput) { + this.identifierInput.readOnly = true; + this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.loading'); + } + + try { + const url = `${this.urlValue}?name=${encodeURIComponent(name)}`; + const response = await fetch(url, {headers: {Accept: 'application/json'}}); + + if (!response.ok) return; + + const data = await response.json() as { identifier:string }; + if (this.identifierInput) { + this.identifierInput.value = data.identifier; + } + } finally { + if (this.identifierInput) { + this.identifierInput.readOnly = false; + this.identifierInput.placeholder = ''; + } + } + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 9467ed961d4..aa975c62b62 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -25,6 +25,7 @@ import StemsController from './controllers/dynamic/work-packages/activities-tab/ import EditorController from './controllers/dynamic/work-packages/activities-tab/editor.controller'; import LazyPageController from './controllers/dynamic/work-packages/activities-tab/lazy-page.controller'; import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; +import IdentifierSuggestionController from './controllers/dynamic/projects/identifier-suggestion.controller'; import AutoSubmit from '@stimulus-components/auto-submit'; import RevealController from '@stimulus-components/reveal'; @@ -82,6 +83,7 @@ OpenProjectStimulusApplication.preregister('external-links', ExternalLinksContro OpenProjectStimulusApplication.preregister('highlight-target-element', HighlightTargetElementController); OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeController); OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); +OpenProjectStimulusApplication.preregister('projects--identifier-suggestion', IdentifierSuggestionController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); diff --git a/spec/forms/projects/settings/editable_identifier_form_spec.rb b/spec/forms/projects/settings/editable_identifier_form_spec.rb new file mode 100644 index 00000000000..45cdce0f11f --- /dev/null +++ b/spec/forms/projects/settings/editable_identifier_form_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do + include_context "with rendered form" + + let(:model) { build_stubbed(:project, identifier: "my-project") } + + context "when the feature flag is off" do + before do + with_flags(semantic_work_package_ids: false) + vc_render_form + end + + it "renders an editable field with the legacy caption" do + expect(page).to have_field "Identifier", with: "my-project", disabled: false + expect(page).to have_text I18n.t("projects.settings.change_identifier_format_hint_legacy") + end + end + + context "when the feature flag is on and the alphanumeric setting is active" do + before do + with_flags(semantic_work_package_ids: true) + allow(Setting::WorkPackageIdentifier).to receive(:alphanumeric?).and_return(true) + vc_render_form + end + + it "renders an editable field with the semantic caption" do + expect(page).to have_field "Identifier", with: "my-project", disabled: false + expect(page).to have_text I18n.t("projects.settings.change_identifier_format_hint_semantic") + end + end + + context "when the feature flag is on but the setting is numeric" do + before do + with_flags(semantic_work_package_ids: true) + allow(Setting::WorkPackageIdentifier).to receive(:alphanumeric?).and_return(false) + vc_render_form + end + + it "renders an editable field with the legacy caption" do + expect(page).to have_field "Identifier", with: "my-project", disabled: false + expect(page).to have_text I18n.t("projects.settings.change_identifier_format_hint_legacy") + end + end +end diff --git a/spec/models/permitted_params_spec.rb b/spec/models/permitted_params_spec.rb index afdd270a5d5..d786dd21af3 100644 --- a/spec/models/permitted_params_spec.rb +++ b/spec/models/permitted_params_spec.rb @@ -323,6 +323,12 @@ RSpec.describe PermittedParams do it_behaves_like "allows params" end + + context "with identifier" do + let(:hash) { { "name" => "Brand New Project", "workspace_type" => "project", "identifier" => "BNP" } } + + it_behaves_like "allows params" + end end describe "#copy_project_options" do diff --git a/spec/requests/projects/identifier_suggestions_spec.rb b/spec/requests/projects/identifier_suggestions_spec.rb new file mode 100644 index 00000000000..bda382bb704 --- /dev/null +++ b/spec/requests/projects/identifier_suggestions_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe "GET /projects/identifier_suggestion", type: :rails_request do + current_user { create(:user) } + + context "when the feature flag is off" do + before { with_flags(semantic_work_package_ids: false) } + + it "returns 404" do + get "/projects/identifier_suggestion", params: { name: "My Project" }, as: :json + expect(response).to have_http_status(:not_found) + end + end + + context "when the feature flag is on" do + before { with_flags(semantic_work_package_ids: true) } + + it "returns a suggested identifier derived from the name" do + get "/projects/identifier_suggestion", params: { name: "Flight Planning Algorithm" }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["identifier"]).to eq("FPA") + end + + it "returns 422 when name is blank" do + get "/projects/identifier_suggestion", params: { name: "" }, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + + context "when not logged in" do + current_user { User.anonymous } + + it "requires login" do + get "/projects/identifier_suggestion", params: { name: "Test" }, as: :json + expect(response).to have_http_status(:unauthorized).or have_http_status(:redirect) + end + end + end +end diff --git a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb index 6ec0b70c3bf..1ace89feb4d 100644 --- a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb @@ -81,6 +81,16 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator end end + describe ".suggest_for_name" do + it "returns an acronym derived from a multi-word name" do + expect(described_class.suggest_for_name("Flight Planning Algorithm")).to eq("FPA") + end + + it "returns an uppercase prefix for a single-word name" do + expect(described_class.suggest_for_name("Banana")).to eq("BAN") + end + end + describe "handle generation from project name" do { # Single-word names: first SINGLE_WORD_LENGTH (3) transliterated chars From db6cc8ba22b5bbc28c5803ca6474f5687e117df9 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 21:53:18 +0100 Subject: [PATCH 113/435] [#72856] Add semantic identifier support to "Copy Project" --- app/components/projects/copy_form_component.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/projects/copy_form_component.html.erb b/app/components/projects/copy_form_component.html.erb index fc8b4d6de6d..e2c7cc46b65 100644 --- a/app/components/projects/copy_form_component.html.erb +++ b/app/components/projects/copy_form_component.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - component_wrapper do + component_wrapper(data: identifier_suggestion_data) do settings_primer_form_with(model: target_project, url: copy_project_path(source_project), method: :post) do |f| flex_layout do |container| container.with_row(mb: 3) do @@ -41,6 +41,7 @@ See COPYRIGHT and LICENSE files for more details. render( Primer::Forms::FormList.new( Projects::Settings::NameForm.new(f), + Projects::Settings::EditableIdentifierForm.new(f), Projects::Settings::RelationsForm.new(f, invisible: params[:parent_id].present?), Projects::Settings::CustomFieldsForm.new(f) ) From 836dc38d04487cf7f684235007bc36c4e2e8eb95 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 22:06:53 +0100 Subject: [PATCH 114/435] restore the test for the original change identifier form --- spec/features/projects/edit_settings_spec.rb | 34 ++++++++++++-------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index 336e4f7f30b..c9599f9cee1 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -52,24 +52,32 @@ RSpec.describe "Projects", "editing settings", :js do end describe "identifier edit" do - before { with_flags(semantic_work_package_ids: true) } - - it "updates the project identifier via dialog" do - visit project_settings_general_path(project) - + it "updates the project identifier" do + visit projects_path + click_on project.name + click_on "Project settings" click_on "Change identifier" - expect(page).to have_dialog "Change project identifier" + expect(page).to have_content "Change the project's identifier".upcase + expect(page).to have_current_path "/projects/foo-project/identifier" - within "dialog" do - expect(page).to have_text "This will permanently change identifiers and URLs" - fill_in "project[identifier]", with: "foo-bar" - click_on "Change identifier" - end + fill_in "project[identifier]", with: "foo-bar" + click_on "Update" expect(page).to have_content "Successful update." - expect(page).to have_current_path %r{/projects/foo-bar/settings/general} - expect(project.reload.identifier).to eq "foo-bar" + expect(page) + .to have_current_path %r{/projects/foo-bar/settings/general} + expect(Project.first.identifier).to eq "foo-bar" + end + + it "displays error messages on invalid input" do + visit project_identifier_path(project) + + 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" end end From e480ed0ea13496460142e2525eea9d34fbcd25f3 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 22:07:33 +0100 Subject: [PATCH 115/435] restore the test for the original change identifier form --- spec/features/projects/edit_settings_spec.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index c9599f9cee1..c1922e85c8d 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -79,6 +79,26 @@ RSpec.describe "Projects", "editing settings", :js do expect(page).to have_content "Identifier is invalid." expect(page).to have_current_path "/projects/foo-project/identifier" end + + context "with the semantic work package IDs flag enabled", with_flag: { semantic_work_package_ids: true } do + it "updates the project identifier via dialog" do + visit project_settings_general_path(project) + + click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + expect(page).to have_text "This will permanently change identifiers and URLs" + fill_in "project[identifier]", with: "foo-bar" + click_on "Change identifier" + end + + expect(page).to have_content "Successful update." + expect(page).to have_current_path %r{/projects/foo-bar/settings/general} + expect(project.reload.identifier).to eq "foo-bar" + end + end end describe "editing basic details" do From 1c056063864cbd1a51371637c2791748ea6efb26 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 22:36:19 +0100 Subject: [PATCH 116/435] switch form validation to Primer DSL --- ...hange_identifier_dialog_component.html.erb | 26 ++++--------------- .../change_identifier_dialog_component.rb | 1 + .../projects/identifier_controller.rb | 5 +++- .../settings/editable_identifier_form.rb | 2 +- app/models/project.rb | 9 +++++-- config/locales/en.yml | 3 +++ 6 files changed, 21 insertions(+), 25 deletions(-) diff --git a/app/components/projects/settings/change_identifier_dialog_component.html.erb b/app/components/projects/settings/change_identifier_dialog_component.html.erb index ea86981031b..16fe257b255 100644 --- a/app/components/projects/settings/change_identifier_dialog_component.html.erb +++ b/app/components/projects/settings/change_identifier_dialog_component.html.erb @@ -36,27 +36,11 @@ See COPYRIGHT and LICENSE files for more details. <%= t("projects.settings.change_identifier_warning") %> <% end %> - <%= form_with model: project, - url: project_identifier_path(project), - id: "change-identifier-form", - class: "mt-3" do |f| %> - <% if Project.semantic_alphanumeric_identifier? %> - <%= f.text_field :identifier, - class: "FormControl-input width-full", - pattern: "[A-Z][A-Z0-9_]*", - maxlength: 10, - required: true, - title: t(:text_project_identifier_handle_format) %> -

<%= t("projects.settings.change_identifier_format_hint_semantic") %>

- <% else %> - <%= f.text_field :identifier, - class: "FormControl-input width-full", - pattern: "[a-z][a-z0-9\\-_]*", - maxlength: 100, - required: true, - title: t(:text_project_identifier_format) %> -

<%= t("projects.settings.change_identifier_format_hint_legacy") %>

- <% end %> + <%= settings_primer_form_with(model: project, + url: project_identifier_path(project), + id: "change-identifier-form", + class: "mt-4") do |f| %> + <%= render(Primer::Forms::FormList.new(Projects::Settings::EditableIdentifierForm.new(f))) %> <% end %> <% end %> diff --git a/app/components/projects/settings/change_identifier_dialog_component.rb b/app/components/projects/settings/change_identifier_dialog_component.rb index fb3e97da957..ef586851a72 100644 --- a/app/components/projects/settings/change_identifier_dialog_component.rb +++ b/app/components/projects/settings/change_identifier_dialog_component.rb @@ -32,6 +32,7 @@ module Projects module Settings class ChangeIdentifierDialogComponent < ApplicationComponent include OpPrimer::ComponentHelpers + include OpPrimer::FormHelpers include OpTurbo::Streamable attr_reader :project diff --git a/app/controllers/projects/identifier_controller.rb b/app/controllers/projects/identifier_controller.rb index fd2b4376f1d..99f1c5601b8 100644 --- a/app/controllers/projects/identifier_controller.rb +++ b/app/controllers/projects/identifier_controller.rb @@ -45,7 +45,10 @@ class Projects::IdentifierController < ApplicationController if service_call.success? flash[:notice] = I18n.t(:notice_successful_update) redirect_to project_settings_general_path(@project) - else + elsif OpenProject::FeatureDecisions.semantic_work_package_ids_active? # Handle error for the new modal + respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project), + status: :unprocessable_entity + else # Handle error for the legacy standalone identifier setting page render action: "show", status: :unprocessable_entity end end diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb index c08053ebd28..15578152518 100644 --- a/app/forms/projects/settings/editable_identifier_form.rb +++ b/app/forms/projects/settings/editable_identifier_form.rb @@ -37,7 +37,7 @@ module Projects label: attribute_name(:identifier), caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), required: true, - maxlength: 10, + maxlength: Project::SEMANTIC_IDENTIFIER_MAX_LENGTH, validation_message: validation_message_for(:identifier) ) else diff --git a/app/models/project.rb b/app/models/project.rb index 2e12211434c..5aa8729bfb9 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -45,6 +45,7 @@ class Project < ApplicationRecord # Maximum length for project identifiers IDENTIFIER_MAX_LENGTH = 100 + SEMANTIC_IDENTIFIER_MAX_LENGTH = 10 # reserved identifiers RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog].freeze @@ -214,10 +215,14 @@ class Project < ApplicationRecord # When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules. validates :identifier, - format: { with: /\A[A-Z][A-Z0-9_]*\z/ }, - length: { maximum: 10 }, + format: { with: /\A[A-Z]/, message: :must_start_with_letter }, if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? } + validates :identifier, + format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, + length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH }, + if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? && p.identifier.match?(/\A[A-Z]/) } + validates_associated :repository, :wiki friendly_id :identifier, use: :finders diff --git a/config/locales/en.yml b/config/locales/en.yml index 736823fa5d2..07b5d19096a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2022,6 +2022,9 @@ en: project_initiation_request_disabled: "Project initiation request is disabled. It must be enabled to create the artifact work package." types: in_use_by_work_packages: "still in use by work packages: %{types}" + identifier: + must_start_with_letter: "The first character has to be a letter." + no_special_characters: "Special characters not allowed." enabled_modules: dependency_missing: "The module '%{dependency}' needs to be enabled as well since the module '%{module}' depends on it." format: "%{message}" From 932b2d89c825610378d603a63c4eefb3e058e857 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 22:42:45 +0100 Subject: [PATCH 117/435] add more tests --- spec/features/projects/edit_settings_spec.rb | 76 ++++++++++++++++---- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index c1922e85c8d..c677f3ba591 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -81,22 +81,74 @@ RSpec.describe "Projects", "editing settings", :js do end context "with the semantic work package IDs flag enabled", with_flag: { semantic_work_package_ids: true } do - it "updates the project identifier via dialog" do - visit project_settings_general_path(project) + context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do + it "updates the project identifier via dialog" do + visit project_settings_general_path(project) - click_on "Change identifier" - - expect(page).to have_dialog "Change project identifier" - - within "dialog" do - expect(page).to have_text "This will permanently change identifiers and URLs" - fill_in "project[identifier]", with: "foo-bar" click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + expect(page).to have_text "This will permanently change identifiers and URLs" + fill_in "project[identifier]", with: "foo-bar" + click_on "Change identifier" + end + + expect(page).to have_content "Successful update." + expect(page).to have_current_path %r{/projects/foo-bar/settings/general} + expect(project.reload.identifier).to eq "foo-bar" + end + end + + context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do + it "updates the project identifier via dialog" do + visit project_settings_general_path(project) + + click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + expect(page).to have_text "This will permanently change identifiers and URLs" + fill_in "project[identifier]", with: "FOOBAR" + click_on "Change identifier" + end + + expect(page).to have_content "Successful update." + expect(page).to have_current_path %r{/projects/FOOBAR/settings/general} + expect(project.reload.identifier).to eq "FOOBAR" end - expect(page).to have_content "Successful update." - expect(page).to have_current_path %r{/projects/foo-bar/settings/general} - expect(project.reload.identifier).to eq "foo-bar" + it "displays an error when the identifier does not start with a letter" do + visit project_settings_general_path(project) + + click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + fill_in "project[identifier]", with: "123ABC" + click_on "Change identifier" + + expect(page).to have_text "The first character has to be a letter." + end + end + + it "displays an error when the identifier contains special characters" do + visit project_settings_general_path(project) + + click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + fill_in "project[identifier]", with: "FOO@BAR" + click_on "Change identifier" + + expect(page).to have_text "Special characters not allowed." + end + end end end end From 7bf353cce9e22793ea1b6d52022790654f46a21e Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 22:44:17 +0100 Subject: [PATCH 118/435] add a missing permitted param --- app/models/permitted_params.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 6562883140e..f2ba7e41f3f 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -304,7 +304,7 @@ class PermittedParams def new_project params - .expect(project: %i[name description parent_id workspace_type] + [{ custom_comments: {} }]) + .expect(project: %i[name description parent_id workspace_type identifier] + [{ custom_comments: {} }]) .merge(custom_field_values(:project)) end From 08ddbf908b49bb36c993fb57d9e727bc9eb6292a Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 12 Mar 2026 23:09:30 +0100 Subject: [PATCH 119/435] squash a rubocop warning --- app/models/project.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 5aa8729bfb9..df496833f6d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -221,7 +221,10 @@ class Project < ApplicationRecord validates :identifier, format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH }, - if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? && p.identifier.match?(/\A[A-Z]/) } + if: ->(p) { + p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? && + p.identifier.match?(/\A[A-Z]/) + } validates_associated :repository, :wiki From 7e723d4a6777fe07d404a00736ee2401de7271b8 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Fri, 13 Mar 2026 00:02:18 +0100 Subject: [PATCH 120/435] revert changes done to the standalone identifier page --- app/views/projects/identifier/show.html.erb | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/views/projects/identifier/show.html.erb b/app/views/projects/identifier/show.html.erb index c67a155fec7..a0209aa6c83 100644 --- a/app/views/projects/identifier/show.html.erb +++ b/app/views/projects/identifier/show.html.erb @@ -58,17 +58,7 @@ See COPYRIGHT and LICENSE files for more details. <%= styled_label_tag "identifier", Project.human_attribute_name(:identifier), class: "-required" %>
- <% if Project.semantic_alphanumeric_identifier? %> - <%= f.text_field :identifier, - pattern: "[A-Z][A-Z0-9_]*", - maxlength: 10, - title: t(:text_project_identifier_handle_format) %> - <% else %> - <%= f.text_field :identifier, - pattern: "[a-z][a-z0-9\\-_]*", - maxlength: 100, - title: t(:text_project_identifier_format) %> - <% end %> + <%= f.text_field :identifier %> <%= f.submit t(:button_update), class: "button -primary -with-icon icon-checkmark" %> From 5e332d65b9c34c1b2b5ca3719a3b45051b37cb4e Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Fri, 13 Mar 2026 00:49:29 +0100 Subject: [PATCH 121/435] enable the auto-suggest for legacy IDs as well --- app/components/projects/new_component.rb | 9 +++------ .../projects/identifier_suggestions_controller.rb | 2 +- app/models/project.rb | 8 ++++++++ .../dynamic/projects/identifier-suggestion.controller.ts | 6 ++++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/components/projects/new_component.rb b/app/components/projects/new_component.rb index 5cac948a7f5..ee11c72f2ce 100644 --- a/app/components/projects/new_component.rb +++ b/app/components/projects/new_component.rb @@ -45,18 +45,15 @@ module Projects end def identifier_suggestion_data + flag_active = OpenProject::FeatureDecisions.semantic_work_package_ids_active? data = { controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": semantic_identifier? ? "semantic" : "legacy" + "projects--identifier-suggestion-mode-value": Project.semantic_alphanumeric_identifier? ? "semantic" : "legacy" } - data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if semantic_identifier? + data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if flag_active data end - def semantic_identifier? - Project.semantic_alphanumeric_identifier? - end - def workspaces_path workspace_type = if Project.workspace_types.key?(project.workspace_type) project.workspace_type diff --git a/app/controllers/projects/identifier_suggestions_controller.rb b/app/controllers/projects/identifier_suggestions_controller.rb index 6c6c2702a9e..4a948582649 100644 --- a/app/controllers/projects/identifier_suggestions_controller.rb +++ b/app/controllers/projects/identifier_suggestions_controller.rb @@ -41,7 +41,7 @@ module Projects name = params[:name].to_s.strip return render json: {}, status: :unprocessable_entity if name.blank? - identifier = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.suggest_for_name(name) + identifier = Project.suggest_identifier(name) render json: { identifier: } end end diff --git a/app/models/project.rb b/app/models/project.rb index 12e6f4ab191..a6da30dd0b4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -284,6 +284,14 @@ class Project < ApplicationRecord OpenProject::FeatureDecisions.semantic_work_package_ids_active? && Setting::WorkPackageIdentifier.alphanumeric? end + def self.suggest_identifier(name) + if semantic_alphanumeric_identifier? + WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.suggest_for_name(name) + else + name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project" + end + end + def self.selectable_projects Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s) end diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts index 127f5111c0b..319e691c6b4 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -63,8 +63,10 @@ export default class extends Controller { this.identifierInput.addEventListener('input', this.handleInput); if (this.urlValue) { - this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.set_name_first'); - this.identifierInput.readOnly = true; + if (!this.identifierInput.value) { + this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.set_name_first'); + this.identifierInput.readOnly = true; + } this.debouncedSuggest = debounce( (name:string) => this.fetchSuggestion(name), From 9d36c838f069d9b2f00e46eef6fe6181c81e1fde Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Fri, 13 Mar 2026 00:50:57 +0100 Subject: [PATCH 122/435] remove the copy component --- app/components/projects/copy_form_component.rb | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/app/components/projects/copy_form_component.rb b/app/components/projects/copy_form_component.rb index 2c9492f8475..9d1cefd0d77 100644 --- a/app/components/projects/copy_form_component.rb +++ b/app/components/projects/copy_form_component.rb @@ -35,18 +35,5 @@ module Projects include OpTurbo::Streamable options :source_project, :target_project, :copy_options - - def identifier_suggestion_data - data = { - controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": semantic_identifier? ? "semantic" : "legacy" - } - data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if semantic_identifier? - data - end - - def semantic_identifier? - Project.semantic_alphanumeric_identifier? - end end end From aeb3d6eef17bd8fa18a67799ffd84a9a28dd41dc Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Fri, 13 Mar 2026 00:54:34 +0100 Subject: [PATCH 123/435] DRY up the suggestion logic --- .../concerns/identifier_suggestion.rb | 45 +++++++++++++++++++ .../projects/copy_form_component.rb | 1 + app/components/projects/new_component.rb | 11 +---- 3 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 app/components/projects/concerns/identifier_suggestion.rb diff --git a/app/components/projects/concerns/identifier_suggestion.rb b/app/components/projects/concerns/identifier_suggestion.rb new file mode 100644 index 00000000000..c768d332ce5 --- /dev/null +++ b/app/components/projects/concerns/identifier_suggestion.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Projects + module Concerns + module IdentifierSuggestion + def identifier_suggestion_data + flag_active = OpenProject::FeatureDecisions.semantic_work_package_ids_active? + data = { + controller: "projects--identifier-suggestion", + "projects--identifier-suggestion-mode-value": Project.semantic_alphanumeric_identifier? ? "semantic" : "legacy" + } + data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if flag_active + data + end + end + end +end diff --git a/app/components/projects/copy_form_component.rb b/app/components/projects/copy_form_component.rb index 9d1cefd0d77..86913f68525 100644 --- a/app/components/projects/copy_form_component.rb +++ b/app/components/projects/copy_form_component.rb @@ -33,6 +33,7 @@ module Projects include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable + include Projects::Concerns::IdentifierSuggestion options :source_project, :target_project, :copy_options end diff --git a/app/components/projects/new_component.rb b/app/components/projects/new_component.rb index ee11c72f2ce..ffe2faeae9d 100644 --- a/app/components/projects/new_component.rb +++ b/app/components/projects/new_component.rb @@ -33,6 +33,7 @@ module Projects include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable + include Projects::Concerns::IdentifierSuggestion options :project, :template, :step @@ -44,16 +45,6 @@ module Projects { display: :none } unless step == 3 end - def identifier_suggestion_data - flag_active = OpenProject::FeatureDecisions.semantic_work_package_ids_active? - data = { - controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": Project.semantic_alphanumeric_identifier? ? "semantic" : "legacy" - } - data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if flag_active - data - end - def workspaces_path workspace_type = if Project.workspace_types.key?(project.workspace_type) project.workspace_type From 4630e056e9350479228b9f41d6b5a5597a029d5a Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 13 Mar 2026 08:21:13 +0100 Subject: [PATCH 124/435] Guard the table check --- config/initializers/inplace_edit_fields.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index c038e12ec75..f62e5f2c0bf 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -51,10 +51,12 @@ Rails.application.config.to_prepare do "calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent } - CustomField.pluck(:id, :field_format).each do |id, field_format| - component_class = custom_field_format_mappings[field_format] - if component_class - OpenProject::InplaceEdit::FieldRegistry.register("custom_field_#{id}", component_class) + if CustomField.table_exists? + CustomField.pluck(:id, :field_format).each do |id, field_format| + component_class = custom_field_format_mappings[field_format] + if component_class + OpenProject::InplaceEdit::FieldRegistry.register("custom_field_#{id}", component_class) + end end end From 0cba4061d212a25601ac8797a3009ac9f9d9ae27 Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad Date: Fri, 13 Mar 2026 09:08:18 +0100 Subject: [PATCH 125/435] Remove inner scroll of calendar --- .../users/non_working_times/calendar_component.sass | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index 3cc641a8752..a2503e468ea 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -1,15 +1,11 @@ -.users-non-working-times-year-overview - height: 75vh - .users-non-working-times-calendar-view - height: 100% - .op-fc-wrapper - flex-grow: 1 - // Rounded corners for the entire calendar + // Remove the inner scroll .fc-multiMonthYear-view + overflow: unset !important border-radius: var(--borderRadius-medium) !important + inset: unset !important // Global non-working days rendered as FullCalendar background events .fc-bg-event.non-working-day--global From 4871665aebaeb18475ad5c1be105142b1e57d8a0 Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad Date: Fri, 13 Mar 2026 12:11:17 +0100 Subject: [PATCH 126/435] Set the position of calendar to relative --- app/components/users/non_working_times/calendar_component.sass | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index a2503e468ea..459fd9d95cc 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -6,6 +6,7 @@ overflow: unset !important border-radius: var(--borderRadius-medium) !important inset: unset !important + position: relative // Global non-working days rendered as FullCalendar background events .fc-bg-event.non-working-day--global From 66dc6835e7064cc1450cf897037d8e4aab91092c Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 13 Mar 2026 12:57:14 +0100 Subject: [PATCH 127/435] Add unit tests for new inplace edit fields --- .../inplace_edit_field_component_spec.rb | 31 +++- ...nplace_edit_field_dialog_component_spec.rb | 81 +++++++++++ .../boolean_input_component_spec.rb | 64 +++++++++ .../calculated_value_input_component_spec.rb | 53 +++++++ .../date_input_component_spec.rb | 64 +++++++++ .../calculated_value_input_component_spec.rb | 54 +++++++ .../display_field_component_spec.rb | 82 +++++++++++ .../hierarchy_list_component_spec.rb | 69 +++++++++ .../link_input_component_spec.rb | 55 +++++++ .../rich_text_area_component_spec.rb | 19 +-- .../select_list_component_spec.rb | 64 +++++++++ .../user_select_list_component_spec.rb | 57 ++++++++ .../float_input_component_spec.rb | 51 +++++++ .../hierarchy_list_component_spec.rb | 135 ++++++++++++++++++ .../integer_input_component_spec.rb | 50 +++++++ .../link_input_component_spec.rb | 50 +++++++ .../rich_text_area_component_spec.rb | 17 ++- .../select_list_component_spec.rb | 67 +++++++++ .../text_input_component_spec.rb | 17 ++- .../version_select_list_component_spec.rb | 81 +++++++++++ .../inplace_edit_fields_controller_spec.rb | 67 +++++++++ .../inplace_edit/update_registry_spec.rb | 12 ++ 22 files changed, 1229 insertions(+), 11 deletions(-) create mode 100644 spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/boolean_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/calculated_value_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/date_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/link_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/select_list_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/float_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/hierarchy_list_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/integer_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/link_input_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/select_list_component_spec.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/version_select_list_component_spec.rb diff --git a/spec/components/open_project/common/inplace_edit_field_component_spec.rb b/spec/components/open_project/common/inplace_edit_field_component_spec.rb index 1af2a98b5f7..ae4d6601190 100644 --- a/spec/components/open_project/common/inplace_edit_field_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_field_component_spec.rb @@ -98,9 +98,38 @@ RSpec.describe OpenProject::Common::InplaceEditFieldComponent, type: :component render_inline(described_class.new(model: project, attribute: :description, update_registry:)) expect(rendered_content) - .not_to include("click->inplace-edit#request") + .not_to include("click->inplace-edit#request") expect(rendered_content) .to have_no_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_editable") end end + + describe "wrapper" do + let(:allowed_attributes) { %w(description) } + + it "renders a stable key on the wrapper for calculated field refresh" do + render_inline(described_class.new(model: project, attribute: :description, update_registry:)) + + expected_key = "project_#{project.id}_description" + expect(rendered_content) + .to have_css("[data-inplace-edit-stable-key='#{expected_key}']") + end + end + + describe "open_in_dialog" do + let(:allowed_attributes) { %w(description) } + + it "uses the dialog controller on the display field when open_in_dialog is true" do + render_inline( + described_class.new( + model: project, + attribute: :description, + open_in_dialog: true, + update_registry: + ) + ) + + expect(rendered_content).to include("click->inplace-edit#openDialog") + end + end end diff --git a/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb b/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb new file mode 100644 index 00000000000..9c69f74b4fd --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFieldDialogComponent, type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + let(:allowed_attributes) { %w[description] } + let(:contract) do + contract = instance_double(BaseContract) + allow(contract).to receive(:writable?) { |attr| allowed_attributes.include?(attr.to_s) } + allow(contract).to receive(:model).and_return(instance_double(Project)) + contract + end + let(:contract_class) do + instance_double(Class).tap do |klass| + allow(klass).to receive(:new).with(project, User.current).and_return(contract) + end + end + let(:update_registry) do + registry = OpenProject::InplaceEdit::UpdateRegistry.new + registry.register(Project, handler: double, contract: contract_class) + registry + end + + before { allow(User).to receive(:current).and_return(build_stubbed(:user)) } + + it "renders a dialog with the expected ID and label" do + render_inline(described_class.new(model: project, attribute: :description, system_arguments: { update_registry: })) + + expect(rendered_content).to have_css("#inplace-edit-field-dialog--project-#{project.id}--description") + expect(rendered_content).to have_text(Project.human_attribute_name(:description)) + end + + it "uses system_arguments[:label] as dialog title when provided" do + render_inline( + described_class.new( + model: project, + attribute: :description, + system_arguments: { update_registry:, label: "My Label" } + ) + ) + + expect(rendered_content).to have_text("My Label") + end + + it "renders Cancel and Save buttons" do + render_inline(described_class.new(model: project, attribute: :description, system_arguments: { update_registry: })) + + expect(rendered_content).to have_button(I18n.t(:button_cancel)) + expect(rendered_content).to have_button(I18n.t(:button_save)) + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/boolean_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/boolean_input_component_spec.rb new file mode 100644 index 00000000000..8ee422bb8cf --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/boolean_input_component_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::BooleanInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a checkbox for the attribute with stimulus controller attached" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_field("project[name]", type: "checkbox") + expect(rendered_content).to include("click->inplace-edit#submitForm") + end + + it "does not add a submit-on-click Stimulus action whe show_action_buttons is false" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false) + end + end + end + + expect(rendered_content).not_to include("click->inplace-edit#submitForm") + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/calculated_value_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/calculated_value_input_component_spec.rb new file mode 100644 index 00000000000..a77246ac0ec --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/calculated_value_input_component_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a readonly text input without buttons" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_field("project[name]", type: "text", readonly: true) + + expect(rendered_content).to have_no_button(I18n.t(:button_save)) + expect(rendered_content).to have_no_button(I18n.t(:button_cancel)) + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/date_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/date_input_component_spec.rb new file mode 100644 index 00000000000..caa72e0dec9 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/date_input_component_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DateInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a date input for the attribute with Stimulus controller attached" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_field("project[name]", type: "date") + expect(rendered_content).to include("change->inplace-edit#submitForm") + end + + it "does not add a submit-on-change Stimulus action whe show_action_buttons is false" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false) + end + end + end + + expect(rendered_content).not_to include("change->inplace-edit#submitForm") + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb new file mode 100644 index 00000000000..3870988afca --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::CalculatedValueInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project, name: "42") } + + it "never attaches the inplace-edit Stimulus controller, even when writable is passed" do + render_inline( + described_class.new(model: project, attribute: :name, writable: true, truncated: false, id: "cf-42") + ) + + expect(rendered_content).not_to include("click->inplace-edit#request") + expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_editable") + end + + it "renders the not-editable tooltip" do + render_inline( + described_class.new(model: project, attribute: :name, writable: false, truncated: false, id: "cf-42") + ) + + expect(rendered_content).to have_text(I18n.t("custom_fields.calculated_field_not_editable")) + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb new file mode 100644 index 00000000000..8ed340c1ea9 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::DisplayFieldComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project, name: "My project") } + + describe "value rendering" do + it "renders the attribute value" do + render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false)) + + expect(rendered_content).to have_text("My project") + end + + it "renders a placeholder when the value is blank" do + project = build_stubbed(:project, name: nil) + render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("placeholders.default")) + end + + it "renders 'Yes' for a true boolean value" do + project = build_stubbed(:project, public: true) + render_inline(described_class.new(model: project, attribute: :public, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("general_text_yes")) + end + + it "renders 'No' for a false boolean value" do + project = build_stubbed(:project, public: false) + render_inline(described_class.new(model: project, attribute: :public, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("general_text_no")) + end + end + + describe "editability" do + it "marks the display field as editable when writable" do + render_inline(described_class.new(model: project, attribute: :name, writable: true, truncated: false)) + + expect(rendered_content).to have_css(".op-inplace-edit--display-field_editable") + expect(rendered_content).to include("click->inplace-edit#request") + end + + it "does not mark the display field as editable when not writable" do + render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false)) + + expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_editable") + expect(rendered_content).not_to include("click->inplace-edit#request") + end + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb new file mode 100644 index 00000000000..437a67fd8e9 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::HierarchyListComponent, + type: :component, with_ee: [:custom_field_hierarchies] do + include ViewComponent::TestHelpers + + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :hierarchy) } + let(:attribute) { custom_field.attribute_name.to_sym } + + it "renders a placeholder when no value is set" do + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("placeholders.default")) + end + + it "renders the item label for a single hierarchy value" do + item = create(:hierarchy_item, label: "Alpha") + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item.id.to_s) + + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text("Alpha") + end + + context "with a multi-value hierarchy field" do + let(:custom_field) { create(:project_custom_field, :multi_hierarchy) } + + it "renders multiple item labels joined by comma" do + item1 = create(:hierarchy_item, label: "Alpha") + item2 = create(:hierarchy_item, label: "Beta") + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item1.id.to_s) + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item2.id.to_s) + + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text("Alpha, Beta") + end + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/link_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/link_input_component_spec.rb new file mode 100644 index 00000000000..132f140beb8 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/link_input_component_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::LinkInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a link for a URL value" do + without_partial_double_verification do + allow(project).to receive(:homepage).and_return("https://example.com") + render_inline(described_class.new(model: project, attribute: :homepage, writable: false, truncated: false)) + + expect(rendered_content).to have_link("https://example.com", href: "https://example.com") + end + end + + it "renders a placeholder when the value is blank" do + without_partial_double_verification do + allow(project).to receive(:homepage).and_return(nil) + render_inline(described_class.new(model: project, attribute: :homepage, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("placeholders.default")) + end + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb index 5b727108446..5f950f44d95 100644 --- a/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb @@ -40,24 +40,27 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::RichTextAr described_class.new( model: project, attribute: :description, - writable: true + writable: true, + truncated: false ) ) expect(rendered_content).to have_css("h2", text: "Hello") + expect(rendered_content).to include("click->inplace-edit#request") end - it "adds inplace-edit stimulus data when writable" do + it "renders a truncated attribute component when truncated is true" do render_inline( described_class.new( model: project, attribute: :description, - writable: true + writable: false, + truncated: true ) ) - expect(rendered_content) - .to include("data-action=\"click->inplace-edit#request\"") + expect(rendered_content).to have_css("[data-controller='attribute']", text: "Hello") + expect(rendered_content).to have_css("ellipsis-expander") end it "adds no inplace-edit stimulus data when not writable" do @@ -65,11 +68,11 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::RichTextAr described_class.new( model: project, attribute: :description, - writable: false + writable: false, + truncated: false ) ) - expect(rendered_content) - .not_to include("data-action=\"click->inplace-edit#request\"") + expect(rendered_content).not_to include("click->inplace-edit#request") end end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/select_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/select_list_component_spec.rb new file mode 100644 index 00000000000..2994a0554fb --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/select_list_component_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::SelectListComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a single string value" do + without_partial_double_verification do + allow(project).to receive(:status_label).and_return("Active") + render_inline(described_class.new(model: project, attribute: :status_label, writable: false, truncated: false)) + + expect(rendered_content).to have_text("Active") + end + end + + it "renders multiple values joined by comma" do + without_partial_double_verification do + allow(project).to receive(:tag_list).and_return(%w[Alpha Beta]) + render_inline(described_class.new(model: project, attribute: :tag_list, writable: false, truncated: false)) + + expect(rendered_content).to have_text("Alpha, Beta") + end + end + + it "renders a placeholder when the value is blank" do + without_partial_double_verification do + allow(project).to receive(:status_label).and_return(nil) + render_inline(described_class.new(model: project, attribute: :status_label, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("placeholders.default")) + end + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component_spec.rb new file mode 100644 index 00000000000..2ad55dfe1cb --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::UserSelectListComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:user_admin) { create(:admin) } + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :user, projects: [project]) } + let(:attribute) { custom_field.attribute_name.to_sym } + let(:selected_user) { create(:user) } + + before { allow(User).to receive(:current).and_return(user_admin) } + + it "renders the user avatar for a single-value user custom field" do + create(:custom_value, :skip_validations, customized: project, custom_field:, value: selected_user.id.to_s) + render_inline(described_class.new(model: Project.find(project.id), attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_css "opce-principal" + expect(rendered_content).to have_no_text(I18n.t("placeholders.default")) + end + + it "renders the placeholder when no user is selected" do + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("placeholders.default")) + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/float_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/float_input_component_spec.rb new file mode 100644 index 00000000000..12a1f1981af --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/float_input_component_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::FloatInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a number input for the attribute" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_field("project[name]", type: "number") + expect(rendered_content).to include('step="any"') + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/hierarchy_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/hierarchy_list_component_spec.rb new file mode 100644 index 00000000000..20185bb82f0 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/hierarchy_list_component_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::HierarchyListComponent, + type: :component, with_ee: [:custom_field_hierarchies] do + include ViewComponent::TestHelpers + + describe ".open_in_dialog?" do + it "returns true so that the field always opens in a dialog" do + expect(described_class.open_in_dialog?).to be(true) + end + end + + context "with a single-value hierarchy custom field" do + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :hierarchy) } + let(:attribute) { custom_field.attribute_name.to_sym } + let!(:item) { create(:hierarchy_item, label: "Alpha", parent: custom_field.hierarchy_root) } + + def render_component + component_class = described_class + cf_attribute = attribute + cf_label = custom_field.name + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label) + end + end + end + end + + it "renders a filterable-tree-view element" do + render_component + + expect(rendered_content).to have_css("filterable-tree-view") + end + + it "renders a hidden sentinel field to allow clearing the selection" do + render_component + + expect(rendered_content).to have_field("project[custom_field_values][]", type: :hidden, with: "") + end + + it "renders item labels inside the tree" do + render_component + + expect(rendered_content).to have_text("Alpha") + end + + it "renders items with single-select checkmarks" do + render_component + + expect(rendered_content).to have_css(".TreeViewItem-singleSelectCheckmark") + end + end + + context "with a multi-value hierarchy custom field" do + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :multi_hierarchy) } + let(:attribute) { custom_field.attribute_name.to_sym } + let!(:item) { create(:hierarchy_item, label: "Beta", parent: custom_field.hierarchy_root) } + + it "renders items with multi-select checkboxes" do + component_class = described_class + cf_attribute = attribute + cf_label = custom_field.name + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label) + end + end + end + + expect(rendered_content).to have_css(".FormControl-checkbox") + end + end + + context "with a pre-selected item" do + let(:user) { create(:admin) } + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :hierarchy, projects: [project]) } + let(:attribute) { custom_field.attribute_name.to_sym } + let!(:item) { create(:hierarchy_item, label: "Gamma", parent: custom_field.hierarchy_root) } + + before do + allow(User).to receive(:current).and_return(user) + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item.id.to_s) + end + + it "marks the currently selected item as checked" do + component_class = described_class + cf_attribute = attribute + cf_label = custom_field.name + render_in_view_context(Project.find(project.id)) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label) + end + end + end + + expect(rendered_content).to have_css("[aria-checked='true']", text: "Gamma") + end + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/integer_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/integer_input_component_spec.rb new file mode 100644 index 00000000000..f61e3103704 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/integer_input_component_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::IntegerInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a number input for the attribute" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_field("project[name]", type: "number") + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/link_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/link_input_component_spec.rb new file mode 100644 index 00000000000..cc54c839120 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/link_input_component_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::LinkInputComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders a URL input for the attribute" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_field("project[name]", type: "url") + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/rich_text_area_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/rich_text_area_component_spec.rb index 8721f6e0530..c955d69eee2 100644 --- a/spec/components/open_project/common/inplace_edit_fields/rich_text_area_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/rich_text_area_component_spec.rb @@ -43,7 +43,8 @@ RSpec.describe OpenProject::Common::InplaceEditFields::RichTextAreaComponent, render component_class.new( form:, model:, - attribute: :name + attribute: :name, + label: "Name" ) end end @@ -54,4 +55,18 @@ RSpec.describe OpenProject::Common::InplaceEditFields::RichTextAreaComponent, expect(rendered_content).to have_button(I18n.t(:button_save)) expect(rendered_content).to have_button(I18n.t(:button_cancel)) end + + it "omits action buttons when show_action_buttons is false" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false) + end + end + end + + expect(rendered_content).to have_no_button(I18n.t(:button_save)) + expect(rendered_content).to have_no_button(I18n.t(:button_cancel)) + end end diff --git a/spec/components/open_project/common/inplace_edit_fields/select_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/select_list_component_spec.rb new file mode 100644 index 00000000000..7f28ad91c04 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/select_list_component_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::SelectListComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:project) { build_stubbed(:project) } + + it "renders save and cancel buttons" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name") + end + end + end + + expect(rendered_content).to have_css("opce-autocompleter") + expect(rendered_content).to have_button(I18n.t(:button_save)) + expect(rendered_content).to have_button(I18n.t(:button_cancel)) + end + + it "omits action buttons when show_action_buttons is false" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false) + end + end + end + + expect(rendered_content).to have_css("opce-autocompleter") + expect(rendered_content).to have_no_button(I18n.t(:button_save)) + expect(rendered_content).to have_no_button(I18n.t(:button_cancel)) + end +end diff --git a/spec/components/open_project/common/inplace_edit_fields/text_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/text_input_component_spec.rb index 9eec1d19f83..58bf150eb05 100644 --- a/spec/components/open_project/common/inplace_edit_fields/text_input_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/text_input_component_spec.rb @@ -43,12 +43,27 @@ RSpec.describe OpenProject::Common::InplaceEditFields::TextInputComponent, render component_class.new( form:, model:, - attribute: :name + attribute: :name, + label: "Name" ) end end end expect(rendered_content).to have_field("project[name]", type: "text") + expect(rendered_content).to include("keydown.esc->inplace-edit#request") + end + + it "does not add a submit-on-change Stimulus action whe show_action_buttons is false" do + component_class = described_class + render_in_view_context(project) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false) + end + end + end + + expect(rendered_content).not_to include("keydown.esc->inplace-edit#request") end end diff --git a/spec/components/open_project/common/inplace_edit_fields/version_select_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/version_select_list_component_spec.rb new file mode 100644 index 00000000000..6ab67644def --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/version_select_list_component_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::VersionSelectListComponent, + type: :component do + include ViewComponent::TestHelpers + + let(:user_admin) { create(:admin) } + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :version, projects: [project]) } + let(:attribute) { custom_field.attribute_name.to_sym } + let(:version1) { create(:version, name: "v1.0", project:) } + let(:version2) { create(:version, name: "v2.0", project:) } + + before { allow(User).to receive(:current).and_return(user_admin) } + + def render_component(project_model) + component_class = described_class + cf_attribute = attribute + cf_label = custom_field.name + render_in_view_context(project_model) do |model| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |form| + render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label) + end + end + end + end + + it "renders an autocompleter for a version custom field" do + version1 + version2 + render_component(project) + + expect(rendered_content).to have_css("opce-autocompleter") + # Options are serialised as JSON in the opce-autocompleter's items attribute + expect(rendered_content).to include("v1.0") + expect(rendered_content).to include("v2.0") + end + + it "marks the currently selected version via the model attribute" do + version1 + create(:custom_value, :skip_validations, customized: project, custom_field:, value: version1.id.to_s) + render_component(Project.find(project.id)) + + # The decorated autocompleter serialises the selected item as a data-model attribute + expect(page).to have_element "opce-autocompleter" do |autocompleter| + expect(autocompleter["data-model"]).to be_json_eql( + %{{"disabled": false, "group_by": "#{project.name}", "name": "#{version1.name}", "selected": true}} + ) + end + end +end diff --git a/spec/controllers/inplace_edit_fields_controller_spec.rb b/spec/controllers/inplace_edit_fields_controller_spec.rb index 3c31ca2d4c2..70fdc965a5d 100644 --- a/spec/controllers/inplace_edit_fields_controller_spec.rb +++ b/spec/controllers/inplace_edit_fields_controller_spec.rb @@ -67,6 +67,21 @@ RSpec.describe InplaceEditFieldsController do end end + describe "GET #dialog" do + let(:handler) { double } + + it "returns a turbo stream response with the dialog component" do + get :dialog, params: { + model: model_param, + id: model.id, + attribute: + }, format: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + end + describe "PATCH #update" do let(:handler) { double(call: success) } @@ -106,6 +121,58 @@ RSpec.describe InplaceEditFieldsController do end end + context "when successful and system_arguments contain a wrapper_id (dialog context)" do + let(:handler) { double(call: true) } + let(:wrapper_id) { "#my-inplace-dialog" } + + it "includes a turbo stream to close the dialog" do + patch :update, params: { + model: model_param, + id: model.id, + attribute:, + project: { name: "New project" }, + system_arguments_json: { wrapper_id: }.to_json + }, format: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include("my-inplace-dialog") + end + end + + context "when attribute is a custom field (hash params via fields_for)" do + let(:handler) { double(call: true) } + let(:custom_field) { create(:project_custom_field) } + let(:attribute) { custom_field.attribute_name.to_sym } + + it "accepts custom_field_values hash params and returns ok" do + patch :update, params: { + model: model_param, + id: model.id, + attribute:, + project: { custom_field_values: { custom_field.id.to_s => "Option A" } } + }, format: :turbo_stream + + expect(response).to have_http_status(:ok) + end + end + + context "when attribute is a custom field (array params from FilterableTreeView)" do + let(:handler) { double(call: true) } + let(:custom_field) { create(:project_custom_field) } + let(:attribute) { custom_field.attribute_name.to_sym } + + it "accepts custom_field_values array params and returns ok" do + patch :update, params: { + model: model_param, + id: model.id, + attribute:, + project: { custom_field_values: ["{\"value\":\"42\"}", ""] } + }, format: :turbo_stream + + expect(response).to have_http_status(:ok) + end + end + context "when no update handler is registered" do let(:handler) { nil } diff --git a/spec/lib/open_project/inplace_edit/update_registry_spec.rb b/spec/lib/open_project/inplace_edit/update_registry_spec.rb index bb4e57f6769..2ee7c994e2a 100644 --- a/spec/lib/open_project/inplace_edit/update_registry_spec.rb +++ b/spec/lib/open_project/inplace_edit/update_registry_spec.rb @@ -55,4 +55,16 @@ RSpec.describe OpenProject::InplaceEdit::UpdateRegistry do expect(registry.registered?(Project)).to be(false) end end + + describe "#resolve_model_class" do + it "returns the model class for a registered param string" do + registry.register(Project, handler:, contract:) + + expect(registry.resolve_model_class("project")).to eq(Project) + end + + it "returns nil for an unregistered param string" do + expect(registry.resolve_model_class("unknown")).to be_nil + end + end end From 7bdd4b4503e4ec808f7996ac797badabdc225700 Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 13 Mar 2026 12:08:34 +0100 Subject: [PATCH 128/435] hide story/task/wiki setting in backlogs and ensure new code doesn`t rely on it --- .../controllers/rb_application_controller.rb | 2 + .../admin/settings/backlogs_settings_form.rb | 114 ++++++++++-------- .../backlogs/app/helpers/rb_common_helper.rb | 4 + .../backlogs/patches/type_patch.rb | 4 + .../backlogs/patches/work_package_patch.rb | 10 ++ .../backlogs/work_package_filter.rb | 2 + .../features/admin/backlogs_settings_spec.rb | 7 ++ .../features/backlogs/create_story_spec.rb | 5 - .../spec/features/sprints/create_spec.rb | 8 -- .../spec/features/sprints/edit_spec.rb | 8 -- 10 files changed, 90 insertions(+), 74 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_application_controller.rb b/modules/backlogs/app/controllers/rb_application_controller.rb index 83906ae31a7..41bbd5a7610 100644 --- a/modules/backlogs/app/controllers/rb_application_controller.rb +++ b/modules/backlogs/app/controllers/rb_application_controller.rb @@ -51,6 +51,8 @@ class RbApplicationController < ApplicationController end def check_if_plugin_is_configured + return if OpenProject::FeatureDecisions.scrum_projects_active? + settings = Setting.plugin_openproject_backlogs if settings["story_types"].blank? || settings["task_type"].blank? respond_to do |format| diff --git a/modules/backlogs/app/forms/admin/settings/backlogs_settings_form.rb b/modules/backlogs/app/forms/admin/settings/backlogs_settings_form.rb index 521a694523f..66b41e067dd 100644 --- a/modules/backlogs/app/forms/admin/settings/backlogs_settings_form.rb +++ b/modules/backlogs/app/forms/admin/settings/backlogs_settings_form.rb @@ -34,60 +34,62 @@ module Admin include ::Settings::FormHelper form do |f| - f.autocompleter( - name: :story_types, - label: I18n.t(:backlogs_story_type), - caption: setting_caption(:plugin_openproject_backlogs, :story_types), - autocomplete_options: { - multiple: true, - closeOnSelect: false, - clearable: false, - decorated: true, - data: { - admin__backlogs_settings_target: "storyTypes", - test_selector: "story_type_autocomplete" + unless scrum_projects_active? + f.autocompleter( + name: :story_types, + label: I18n.t(:backlogs_story_type), + caption: setting_caption(:plugin_openproject_backlogs, :story_types), + autocomplete_options: { + multiple: true, + closeOnSelect: false, + clearable: false, + decorated: true, + data: { + admin__backlogs_settings_target: "storyTypes", + test_selector: "story_type_autocomplete" + } } - } - ) do |list| - available_types.each do |label, value| - active = value.in?(Story.types) - in_use = Task.type == value + ) do |list| + available_types.each do |label, value| + active = value.in?(Story.types) + in_use = Task.type == value - list.option( - label:, - value:, - selected: active, - disabled: in_use - ) + list.option( + label:, + value:, + selected: active, + disabled: in_use + ) + end end - end - f.autocompleter( - name: :task_type, - label: I18n.t(:backlogs_task_type), - caption: setting_caption(:plugin_openproject_backlogs, :task_type), - input_width: :small, - autocomplete_options: { - multiple: false, - closeOnSelect: true, - clearable: false, - decorated: true, - data: { - admin__backlogs_settings_target: "taskType", - test_selector: "task_type_autocomplete" + f.autocompleter( + name: :task_type, + label: I18n.t(:backlogs_task_type), + caption: setting_caption(:plugin_openproject_backlogs, :task_type), + input_width: :small, + autocomplete_options: { + multiple: false, + closeOnSelect: true, + clearable: false, + decorated: true, + data: { + admin__backlogs_settings_target: "taskType", + test_selector: "task_type_autocomplete" + } } - } - ) do |list| - available_types.each do |label, value| - active = Task.type == value - in_use = value.in?(Story.types) + ) do |list| + available_types.each do |label, value| + active = Task.type == value + in_use = value.in?(Story.types) - list.option( - label:, - value:, - selected: active, - disabled: in_use - ) + list.option( + label:, + value:, + selected: active, + disabled: in_use + ) + end end end @@ -105,11 +107,13 @@ module Admin ) end - f.text_field( - name: :wiki_template, - label: I18n.t(:backlogs_wiki_template), - input_width: :medium - ) + unless scrum_projects_active? + f.text_field( + name: :wiki_template, + label: I18n.t(:backlogs_wiki_template), + input_width: :medium + ) + end f.submit(scheme: :primary, name: :apply, label: I18n.t(:button_save)) end @@ -119,6 +123,10 @@ module Admin def available_types Type.pluck(:name, :id) end + + def scrum_projects_active? + OpenProject::FeatureDecisions.scrum_projects_active? + end end end end diff --git a/modules/backlogs/app/helpers/rb_common_helper.rb b/modules/backlogs/app/helpers/rb_common_helper.rb index 7b3dc947d54..213894005b9 100644 --- a/modules/backlogs/app/helpers/rb_common_helper.rb +++ b/modules/backlogs/app/helpers/rb_common_helper.rb @@ -156,6 +156,8 @@ module RbCommonHelper end def backlogs_types + return [] if scrum_projects_enabled? + @backlogs_types ||= begin backlogs_ids = Setting.plugin_openproject_backlogs["story_types"] backlogs_ids << Setting.plugin_openproject_backlogs["task_type"] @@ -165,6 +167,8 @@ module RbCommonHelper end def story_types + return [] if scrum_projects_enabled? + @story_types ||= begin backlogs_type_ids = Setting.plugin_openproject_backlogs["story_types"].map(&:to_i) diff --git a/modules/backlogs/lib/open_project/backlogs/patches/type_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/type_patch.rb index 122308e6b32..faf5f964ff5 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/type_patch.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/type_patch.rb @@ -39,10 +39,14 @@ module OpenProject::Backlogs::Patches::TypePatch module InstanceMethods def story? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + Story.types.include?(id) end def task? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + Task.type.present? && id == Task.type end end diff --git a/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb index 7e959a3d486..472a1e15b35 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/work_package_patch.rb @@ -50,6 +50,8 @@ module OpenProject::Backlogs::Patches::WorkPackagePatch module ClassMethods def backlogs_types + return [] if OpenProject::FeatureDecisions.scrum_projects_active? + # Unfortunately, this is not cachable so the following line would be wrong # @backlogs_types ||= Story.types << Task.type # Caching like in the line above would prevent the types selected @@ -72,6 +74,8 @@ module OpenProject::Backlogs::Patches::WorkPackagePatch end def is_story? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + backlogs_enabled? && Story.types.include?(type_id) end @@ -80,10 +84,14 @@ module OpenProject::Backlogs::Patches::WorkPackagePatch end def is_task? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + backlogs_enabled? && (parent_id && type_id == Task.type && Task.type.present?) end def is_impediment? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + backlogs_enabled? && (parent_id.nil? && type_id == Task.type && Task.type.present?) end @@ -117,6 +125,8 @@ module OpenProject::Backlogs::Patches::WorkPackagePatch end def in_backlogs_type? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + backlogs_enabled? && WorkPackage.backlogs_types.include?(type.try(:id)) end end diff --git a/modules/backlogs/lib/open_project/backlogs/work_package_filter.rb b/modules/backlogs/lib/open_project/backlogs/work_package_filter.rb index 1d077c98bae..c08d55e753d 100644 --- a/modules/backlogs/lib/open_project/backlogs/work_package_filter.rb +++ b/modules/backlogs/lib/open_project/backlogs/work_package_filter.rb @@ -80,6 +80,8 @@ module OpenProject::Backlogs private def backlogs_configured? + return false if OpenProject::FeatureDecisions.scrum_projects_active? + Story.types.present? && Task.type.present? end diff --git a/modules/backlogs/spec/features/admin/backlogs_settings_spec.rb b/modules/backlogs/spec/features/admin/backlogs_settings_spec.rb index f0e427965ab..7e6d988ba03 100644 --- a/modules/backlogs/spec/features/admin/backlogs_settings_spec.rb +++ b/modules/backlogs/spec/features/admin/backlogs_settings_spec.rb @@ -125,4 +125,11 @@ RSpec.describe "Backlogs Admin Settings", :js do expect(page).to have_field "Template for sprint wiki page", with: "my_sprint_wiki_page" end + + it "hides wiki and types selection on scrum projects feature flag active", with_flag: { scrum_projects: true } do + expect(page).to have_no_field "Template for sprint wiki page" + expect(page).to have_no_css "[data-test-selector='story_type_autocomplete']" + expect(page).to have_no_css "[data-test-selector='task_type_autocomplete']" + expect(page).to have_css "fieldset", text: "Points burn up/down" + end end diff --git a/modules/backlogs/spec/features/backlogs/create_story_spec.rb b/modules/backlogs/spec/features/backlogs/create_story_spec.rb index 37e3d888493..e22da71ffe0 100644 --- a/modules/backlogs/spec/features/backlogs/create_story_spec.rb +++ b/modules/backlogs/spec/features/backlogs/create_story_spec.rb @@ -112,11 +112,6 @@ RSpec.describe "Backlogs", :js do # TODO: removed in OP #57688, to be reimplemented # fill_in "Story Points", with: "5" - # inactive types should not be selectable but the user can choose from the - # active types - # TODO: removed in OP #57688, to be reimplemented - # expect(page).to have_no_css("option", text: inactive_story_type.name) - select_combo_box_option story_type2.name, from: "Type" # saving the new story diff --git a/modules/backlogs/spec/features/sprints/create_spec.rb b/modules/backlogs/spec/features/sprints/create_spec.rb index 2a3b3a4de17..3bc70e498be 100644 --- a/modules/backlogs/spec/features/sprints/create_spec.rb +++ b/modules/backlogs/spec/features/sprints/create_spec.rb @@ -72,14 +72,6 @@ RSpec.describe "Create", :js do before do login_as(user) - # Legacy backlogs module requires type configuration - allow(Setting) - .to receive(:plugin_openproject_backlogs) - .and_return("story_types" => [story_type.id.to_s, - story_type2.id.to_s, - inactive_story_type.id.to_s], - "task_type" => task_type.id.to_s) - backlogs_page.visit! end diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb index a41ba4f2ff3..8e49b34ddbd 100644 --- a/modules/backlogs/spec/features/sprints/edit_spec.rb +++ b/modules/backlogs/spec/features/sprints/edit_spec.rb @@ -94,14 +94,6 @@ RSpec.describe "Edit", :js do before do login_as(user) - # Legacy backlogs module requires type configuration - allow(Setting) - .to receive(:plugin_openproject_backlogs) - .and_return("story_types" => [story_type.id.to_s, - story_type2.id.to_s, - inactive_story_type.id.to_s], - "task_type" => task_type.id.to_s) - backlogs_page.visit! end From 876930230a359cd1a8eb21bbbb8dcdf3c9bed40c Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Fri, 13 Mar 2026 13:24:25 +0100 Subject: [PATCH 129/435] Always show message about missing AMPF, when it's missing The previous check was overly specific. In case when the projectFolderHref was not null, but undefined, it would not correctly recognize the missing project folder. This caused the corresponding alert to be properly shown in the file-picker-modal, but not in the location-picker-modal. --- .../file-picker-base-modal/file-picker-base-modal.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.ts b/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.ts index ca0cb105028..6bec3bbad27 100644 --- a/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.ts +++ b/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.ts @@ -173,7 +173,7 @@ export abstract class FilePickerBaseModalComponent extends OpModalComponent impl return of('/'); } - if (this.locals.projectFolderMode === 'automatic' && this.locals.projectFolderHref === null) { + if (this.locals.projectFolderMode === 'automatic' && !this.locals.projectFolderHref) { this.showAlert.next('managedFolderNotFound'); return of('/'); } From 0f4f0d11cec3c00f5b566c8cedeac9c55c48b48f Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 13 Mar 2026 14:06:08 +0100 Subject: [PATCH 130/435] Adapt tests to new inplaceEditFields --- .../section_component_spec.rb | 2 +- .../rich_text_area_component_spec.rb | 2 +- .../edit_project_attributes_spec.rb | 46 ------------------- 3 files changed, 2 insertions(+), 48 deletions(-) delete mode 100644 spec/permissions/edit_project_attributes_spec.rb diff --git a/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb b/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb index bc83efe1729..6e6e180d4e4 100644 --- a/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb +++ b/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb @@ -53,6 +53,6 @@ RSpec.describe Overviews::ProjectCustomFields::SectionComponent, type: :componen end it "renders two custom fields" do - expect(rendered_component).to have_css ".op-project-custom-field-container", count: 2 + expect(rendered_component).to have_css ".op-inplace-edit--display-field", count: 2 end end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb index 5f950f44d95..15a78543344 100644 --- a/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component_spec.rb @@ -60,7 +60,7 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::RichTextAr ) expect(rendered_content).to have_css("[data-controller='attribute']", text: "Hello") - expect(rendered_content).to have_css("ellipsis-expander") + expect(rendered_content).to have_css(".ellipsis-expander") end it "adds no inplace-edit stimulus data when not writable" do diff --git a/spec/permissions/edit_project_attributes_spec.rb b/spec/permissions/edit_project_attributes_spec.rb deleted file mode 100644 index 868155eafd7..00000000000 --- a/spec/permissions/edit_project_attributes_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" -require "support/permission_specs" - -RSpec.describe Overviews::ProjectCustomFieldsController, "edit_project_attributes permission", # rubocop:disable RSpec/EmptyExampleGroup,RSpec/SpecFilePathFormat - type: :controller do - include PermissionSpecs - - # render dialog displaying project attributes - check_permission_required_for("overviews/project_custom_fields#show", :view_project_attributes) - - # render dialog with inputs for editing project attributes with edit_project permission - check_permission_required_for("overviews/project_custom_fields#edit", :edit_project_attributes) - - # update project attributes with edit_project permission, deeper permission check via contract in place - check_permission_required_for("overviews/project_custom_fields#update", :edit_project_attributes) -end From aaaaaef73171abecb50244cc8dfef2921700dccb Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 16:18:13 +0300 Subject: [PATCH 131/435] Add suggest_handle static interface to ProjectHandleSuggestionGenerator --- .../project_handle_suggestion_generator.rb | 19 ++++++++++++++++--- ...roject_handle_suggestion_generator_spec.rb | 9 +++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb index 6f60553379f..de261237fb5 100644 --- a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb @@ -54,16 +54,25 @@ module WorkPackages new.call(projects, reserved_handles:, in_use_handles:) end + # Returns a single suggested handle string for the given project name. + # + def self.suggest_handle(name, reserved_handles: Set.new, in_use_handles: Set.new) + new.suggest_handle(name, reserved_handles:, in_use_handles:) + end + def call(projects, reserved_handles:, in_use_handles:) generate_suggestions(projects, reserved_handles:, in_use_handles:) end + def suggest_handle(name, reserved_handles: Set.new, in_use_handles: Set.new) + base = handle_from_name(name) + unique_handle(base, combined_handles(reserved_handles, in_use_handles)) + end + private def generate_suggestions(projects, reserved_handles:, in_use_handles:) - used_handles = Set.new - used_handles.merge(in_use_handles) - used_handles.merge(reserved_handles) + used_handles = combined_handles(reserved_handles, in_use_handles) projects.map do |project| base = handle_from_name(project.name) @@ -133,6 +142,10 @@ module WorkPackages :reserved end end + + def combined_handles(*sets) + sets.reduce(Set.new, :merge) + end end end end diff --git a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb index 6ec0b70c3bf..e017024eecd 100644 --- a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb @@ -181,4 +181,13 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator expect(result.first[:error_reason]).to eq(:too_long) end end + + describe ".suggest_handle" do + it "produces the same handle as .call for the same name" do + project = build_stubbed(:project, name: "Alpha Beta", identifier: "alpha-beta") + batch_result = described_class.call([project]).first[:suggested_handle] + single_result = described_class.suggest_handle("Alpha Beta") + expect(single_result).to eq(batch_result) + end + end end From e9900dfe2d27a81d7e81f35b88f0bfe7b5aba4a2 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 16:38:00 +0300 Subject: [PATCH 132/435] Rename ProjectHandleSuggestionGenerator to ProjectIdentifierSuggestionGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the class name and all internal terminology with the domain language: "handle" → "identifier" throughout. Renames the file, class, constants (HANDLE_MAX_LENGTH → IDENTIFIER_MAX_LENGTH, FALLBACK_HANDLE → FALLBACK_IDENTIFIER), public API (suggest_handle → suggest_identifier, suggested_handle hash key → suggested_identifier), keyword arguments (in_use_handles → in_use_identifiers, reserved_handles → reserved_identifiers), and private helper methods accordingly. All call-sites and specs updated to match. --- ...ntifier_autofix_section_component.html.erb | 4 +- .../identifier_autofix/preview_query.rb | 12 +-- ...roject_identifier_suggestion_generator.rb} | 84 ++++++++--------- ...entifier_autofix_section_component_spec.rb | 2 +- ...identifier_settings_form_component_spec.rb | 2 +- .../identifier_autofix/preview_query_spec.rb | 6 +- ...t_identifier_suggestion_generator_spec.rb} | 90 +++++++++---------- 7 files changed, 100 insertions(+), 100 deletions(-) rename app/services/work_packages/identifier_autofix/{project_handle_suggestion_generator.rb => project_identifier_suggestion_generator.rb} (57%) rename spec/services/work_packages/identifier_autofix/{project_handle_suggestion_generator_spec.rb => project_identifier_suggestion_generator_spec.rb} (67%) diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb index 24d7c6cbd96..a9ba02a0445 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb @@ -86,10 +86,10 @@ end end row.with_column(flex: 1) do - render(Primer::Beta::Text.new) { entry[:suggested_handle] } + render(Primer::Beta::Text.new) { entry[:suggested_identifier] } end row.with_column(flex: 1) do - render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_handle]) } + render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_identifier]) } end end end diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb index 63a8fd1b806..475516150e3 100644 --- a/app/services/work_packages/identifier_autofix/preview_query.rb +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -41,10 +41,10 @@ module WorkPackages .limit(DISPLAY_COUNT) .to_a - suggestions = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.call( + suggestions = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.call( preview, - in_use_handles:, - reserved_handles: + in_use_identifiers:, + reserved_identifiers: ) Result.new(projects_data: suggestions, total_count: total) @@ -58,16 +58,16 @@ module WorkPackages def problematic_scope @problematic_scope ||= Project.where( "length(identifier) > ? OR identifier ~ ?", - WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH, + WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator::IDENTIFIER_MAX_LENGTH, "[^a-zA-Z0-9]" ) end - def in_use_handles + def in_use_identifiers Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set end - def reserved_handles + def reserved_identifiers # TODO: OldProjectIdentifier.pluck(:identifier).to_set # once the OldProjectIdentifier model and migration are added. Set.new diff --git a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb similarity index 57% rename from app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb rename to app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index de261237fb5..3738a921b4b 100644 --- a/app/services/work_packages/identifier_autofix/project_handle_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -39,111 +39,111 @@ module WorkPackages # # Each result entry includes an error_reason classifying why the project's # current identifier is problematic: - # - :too_long — identifier length exceeds HANDLE_MAX_LENGTH + # - :too_long — identifier length exceeds IDENTIFIER_MAX_LENGTH # - :special_characters — identifier contains characters outside [a-zA-Z0-9] - # - :in_use — identifier is another project's active handle - # - :reserved — identifier appears in another project's handle history + # - :in_use — identifier is another project's active identifier + # - :reserved — identifier appears in another project's identifier history # - class ProjectHandleSuggestionGenerator - HANDLE_MAX_LENGTH = 5 + class ProjectIdentifierSuggestionGenerator + IDENTIFIER_MAX_LENGTH = 5 SINGLE_WORD_LENGTH = 3 - FALLBACK_HANDLE = "PROJ" + FALLBACK_IDENTIFIER = "PROJ" SUFFIX_LIMIT = 10_000 - def self.call(projects, reserved_handles: Set.new, in_use_handles: Set.new) - new.call(projects, reserved_handles:, in_use_handles:) + def self.call(projects, reserved_identifiers: Set.new, in_use_identifiers: Set.new) + new.call(projects, reserved_identifiers:, in_use_identifiers:) end - # Returns a single suggested handle string for the given project name. + # Returns a single suggested identifier string for the given project name. # - def self.suggest_handle(name, reserved_handles: Set.new, in_use_handles: Set.new) - new.suggest_handle(name, reserved_handles:, in_use_handles:) + def self.suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new) + new.suggest_identifier(name, reserved_identifiers:, in_use_identifiers:) end - def call(projects, reserved_handles:, in_use_handles:) - generate_suggestions(projects, reserved_handles:, in_use_handles:) + def call(projects, reserved_identifiers:, in_use_identifiers:) + generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:) end - def suggest_handle(name, reserved_handles: Set.new, in_use_handles: Set.new) - base = handle_from_name(name) - unique_handle(base, combined_handles(reserved_handles, in_use_handles)) + def suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new) + base = identifier_from_name(name) + unique_identifier(base, combined_identifiers(reserved_identifiers, in_use_identifiers)) end private - def generate_suggestions(projects, reserved_handles:, in_use_handles:) - used_handles = combined_handles(reserved_handles, in_use_handles) + def generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:) + used_identifiers = combined_identifiers(reserved_identifiers, in_use_identifiers) projects.map do |project| - base = handle_from_name(project.name) - handle = unique_handle(base, used_handles) - used_handles << handle + base = identifier_from_name(project.name) + identifier = unique_identifier(base, used_identifiers) + used_identifiers << identifier { project:, current_identifier: project.identifier, - suggested_handle: handle, - error_reason: error_reason(project.identifier, reserved_handles:, in_use_handles:) + suggested_identifier: identifier, + error_reason: error_reason(project.identifier, reserved_identifiers:, in_use_identifiers:) } end end - def handle_from_name(name) + def identifier_from_name(name) # Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside # their word rather than treated as separators by the ASCII-only [a-zA-Z]. words = name.to_s.scan(/[[:alpha:][:digit:]]+/) - return FALLBACK_HANDLE if words.empty? + return FALLBACK_IDENTIFIER if words.empty? - words.size == 1 ? handle_from_single_word(words.first) : handle_from_words(words) + words.size == 1 ? identifier_from_single_word(words.first) : identifier_from_words(words) end - def handle_from_single_word(word) - # e.g. "Banana" → "BAN", "Kiwi" → "KIW", "日本語" → FALLBACK_HANDLE + def identifier_from_single_word(word) + # e.g. "Banana" → "BAN", "Kiwi" → "KIW", "日本語" → FALLBACK_IDENTIFIER t = I18n.with_locale(:en) { I18n.transliterate(word) } chars = t.scan(/[A-Za-z0-9]/).first(SINGLE_WORD_LENGTH).map(&:upcase).join - chars.empty? ? FALLBACK_HANDLE : chars + chars.empty? ? FALLBACK_IDENTIFIER : chars end - def handle_from_words(words) + def identifier_from_words(words) # Multi-word names: take initials (first letter of each word), truncated. acronym = words.filter_map do |word| ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] ch if ch&.match?(/\A[A-Z0-9]\z/) end.join - return FALLBACK_HANDLE if acronym.empty? + return FALLBACK_IDENTIFIER if acronym.empty? - acronym.slice(0, HANDLE_MAX_LENGTH) + acronym.slice(0, IDENTIFIER_MAX_LENGTH) end - def unique_handle(base, used_handles) - return base unless used_handles.include?(base) + def unique_identifier(base, used_identifiers) + return base unless used_identifiers.include?(base) counter = 2 loop do - raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ + raise "Could not find a unique identifier for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ if counter > SUFFIX_LIMIT suffix = counter.to_s - candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}" - break candidate unless used_handles.include?(candidate) + candidate = "#{base.slice(0, IDENTIFIER_MAX_LENGTH - suffix.length)}#{suffix}" + break candidate unless used_identifiers.include?(candidate) counter += 1 end end - def error_reason(identifier, reserved_handles:, in_use_handles:) - if identifier.length > HANDLE_MAX_LENGTH + def error_reason(identifier, reserved_identifiers:, in_use_identifiers:) + if identifier.length > IDENTIFIER_MAX_LENGTH :too_long elsif identifier.match?(/[^a-zA-Z0-9]/) :special_characters - elsif in_use_handles.include?(identifier) + elsif in_use_identifiers.include?(identifier) :in_use - elsif reserved_handles.include?(identifier) + elsif reserved_identifiers.include?(identifier) :reserved end end - def combined_handles(*sets) + def combined_identifiers(*sets) sets.reduce(Set.new, :merge) end end diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb index da36555059c..5ddab53698f 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb @@ -38,7 +38,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent, { project:, current_identifier: identifier, - suggested_handle: handle, + suggested_identifier: handle, error_reason: } end diff --git a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb index 1ba8bd21c3e..372dab0e168 100644 --- a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb @@ -131,7 +131,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t let(:problematic_result) do WorkPackages::IdentifierAutofix::PreviewQuery::Result.new( projects_data: [ - { project:, current_identifier: "bad-proj", suggested_handle: "BP", error_reason: :special_characters } + { project:, current_identifier: "bad-proj", suggested_identifier: "BP", error_reason: :special_characters } ], total_count: 1 ) diff --git a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb index 2c9c9c7780f..fc51ae8938c 100644 --- a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb +++ b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb @@ -90,8 +90,8 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do let!(:second_project) { create_problematic_project(name: "Foxtrot Papa", identifier: "foxtrot-papa") } it "does not assign the same handle to both" do - handles = result.projects_data.pluck(:suggested_handle) - expect(handles.uniq.size).to eq(handles.size) + identifiers = result.projects_data.pluck(:suggested_identifier) + expect(identifiers.uniq.size).to eq(identifiers.size) end end @@ -99,6 +99,6 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do create_problematic_project(name: "Alpha Beta", identifier: "alpha-beta") entry = result.projects_data.first - expect(entry).to include(:project, :current_identifier, :suggested_handle, :error_reason) + expect(entry).to include(:project, :current_identifier, :suggested_identifier, :error_reason) end end diff --git a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb similarity index 67% rename from spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb rename to spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb index e017024eecd..fd492fcfb65 100644 --- a/spec/services/work_packages/identifier_autofix/project_handle_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator do +RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator do describe ".call" do context "when given an empty array" do it "returns an empty array" do @@ -47,14 +47,14 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator expect(result.first[:project]).to eq(project) expect(result.first[:current_identifier]).to eq("verylongidentifier") expect(result.first[:error_reason]).to eq(:too_long) - expect(result.first[:suggested_handle]).to be_present - expect(result.first[:suggested_handle].length).to be <= described_class::HANDLE_MAX_LENGTH + expect(result.first[:suggested_identifier]).to be_present + expect(result.first[:suggested_identifier].length).to be <= described_class::IDENTIFIER_MAX_LENGTH end end context "when a project has a special-character identifier" do - # "fs" is 2 chars (≤ HANDLE_MAX_LENGTH) but contains no special chars; - # use "f-s" (3 chars ≤ HANDLE_MAX_LENGTH) to trigger :special_characters. + # "fs" is 2 chars (≤ IDENTIFIER_MAX_LENGTH) but contains no special chars; + # use "f-s" (3 chars ≤ IDENTIFIER_MAX_LENGTH) to trigger :special_characters. shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") } it "returns a suggestion entry with error_reason :special_characters" do @@ -64,83 +64,83 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator end end - context "when multiple projects generate conflicting handles" do + context "when multiple projects generate conflicting identifiers" do shared_let(:project_sc1) { create(:project, identifier: "sc-app", name: "Stream Communicator") } shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") } - it "generates unique handles for each project" do - handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle) - expect(handles.uniq.size).to eq(handles.size) + it "generates unique identifiers for each project" do + identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier) + expect(identifiers.uniq.size).to eq(identifiers.size) end it "appends a numeric suffix to resolve conflicts" do - handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle) - expect(handles).to include("SC") - expect(handles.any? { it.match?(/\ASC\d+\z/) }).to be true + identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier) + expect(identifiers).to include("SC") + expect(identifiers.any? { it.match?(/\ASC\d+\z/) }).to be true end end end - describe "handle generation from project name" do + describe "identifier generation from project name" do { # Single-word names: first SINGLE_WORD_LENGTH (3) transliterated chars "Banana" => "BAN", "Kiwi" => "KIW", "Strawberry" => "STR", "Cécile" => "CEC", # single word with accented letter - # Multi-word names: initials, truncated to HANDLE_MAX_LENGTH (5) + # Multi-word names: initials, truncated to IDENTIFIER_MAX_LENGTH (5) "Flight Planning Algorithm" => "FPA", "Fly & Sky" => "FS", "Social media marketing" => "SMM", "Arcanos (mobile-web-app)" => "AMWA", "Flight Planning Training" => "FPT", - "A B C D E F G H I J K" => "ABCDE", # truncated to HANDLE_MAX_LENGTH (5) + "A B C D E F G H I J K" => "ABCDE", # truncated to IDENTIFIER_MAX_LENGTH (5) "Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"] "étude de cas" => "EDC", # Unicode: é→E via transliteration # Non-Latin scripts have no transliteration entries (I18n.transliterate → "?"). - # All initials are dropped and the name falls back to FALLBACK_HANDLE. + # All initials are dropped and the name falls back to FALLBACK_IDENTIFIER. "日本語プロジェクト" => "PROJ", # Japanese: every initial → "?" → fallback "Plan 日本" => "P" # Mixed: Latin "P" survives; "日" is dropped - }.each do |project_name, expected_handle| - it "generates '#{expected_handle}' from '#{project_name}'" do + }.each do |project_name, expected_identifier| + it "generates '#{expected_identifier}' from '#{project_name}'" do project = create(:project, identifier: "bad-id", name: project_name) - expect(described_class.call([project]).first[:suggested_handle]).to eq(expected_handle) + expect(described_class.call([project]).first[:suggested_identifier]).to eq(expected_identifier) end end end - describe "unique_handle conflict resolution" do - it "uses the base handle when not yet taken" do + describe "unique_identifier conflict resolution" do + it "uses the base identifier when not yet taken" do project = create(:project, identifier: "sc-app", name: "Stream Communicator") - expect(described_class.call([project]).first[:suggested_handle]).to eq("SC") + expect(described_class.call([project]).first[:suggested_identifier]).to eq("SC") end it "increments the suffix until unique" do p1 = create(:project, identifier: "sc-a", name: "Stream Communicator") p2 = create(:project, identifier: "sc-b", name: "Stream Channel") p3 = create(:project, identifier: "sc-c", name: "Something Cool") - expect(described_class.call([p1, p2, p3]).pluck(:suggested_handle)).to contain_exactly("SC", "SC2", "SC3") + expect(described_class.call([p1, p2, p3]).pluck(:suggested_identifier)).to contain_exactly("SC", "SC2", "SC3") end - it "trims the base to fit within HANDLE_MAX_LENGTH when adding a suffix" do + it "trims the base to fit within IDENTIFIER_MAX_LENGTH when adding a suffix" do p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") - handles = described_class.call([p1, p2]).pluck(:suggested_handle) - expect(handles.all? { it.length <= described_class::HANDLE_MAX_LENGTH }).to be true - expect(handles.uniq.size).to eq(2) + identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier) + expect(identifiers.all? { it.length <= described_class::IDENTIFIER_MAX_LENGTH }).to be true + expect(identifiers.uniq.size).to eq(2) end - it "does not suggest a handle that is already in use (pre-seeded collision)" do - # "SC" is pre-seeded as an in-use handle; the generator must skip it and use "SC2". + it "does not suggest an identifier that is already in use (pre-seeded collision)" do + # "SC" is pre-seeded as an in-use identifier; the generator must skip it and use "SC2". project = create(:project, identifier: "sc-app", name: "Stream Communicator") - result = described_class.call([project], in_use_handles: Set["SC"]) - expect(result.first[:suggested_handle]).not_to eq("SC") - expect(result.first[:suggested_handle]).to match(/\ASC\d+\z/) # e.g. "SC2" + result = described_class.call([project], in_use_identifiers: Set["SC"]) + expect(result.first[:suggested_identifier]).not_to eq("SC") + expect(result.first[:suggested_identifier]).to match(/\ASC\d+\z/) # e.g. "SC2" end end describe "error reason assignment" do - it "assigns :too_long when identifier length exceeds HANDLE_MAX_LENGTH" do + it "assigns :too_long when identifier length exceeds IDENTIFIER_MAX_LENGTH" do project = create(:project, identifier: "verylongidentifier", name: "Test") expect(described_class.call([project]).first[:error_reason]).to eq(:too_long) end @@ -155,38 +155,38 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator expect(described_class.call([project]).first[:error_reason]).to eq(:too_long) end - it "assigns :in_use when identifier is another project's active handle" do + it "assigns :in_use when identifier is another project's active identifier" do # "abc" is valid (lowercase alphanumeric, ≤ 5 chars, no special chars) project = create(:project, identifier: "abc", name: "Alpha Beta Corp") - result = described_class.call([project], in_use_handles: Set["abc"]) + result = described_class.call([project], in_use_identifiers: Set["abc"]) expect(result.first[:error_reason]).to eq(:in_use) end - it "assigns :reserved when identifier appears in historical handles" do + it "assigns :reserved when identifier appears in historical identifiers" do project = create(:project, identifier: "abc", name: "Alpha Beta Corp") - result = described_class.call([project], reserved_handles: Set["abc"]) + result = described_class.call([project], reserved_identifiers: Set["abc"]) expect(result.first[:error_reason]).to eq(:reserved) end it "prefers :in_use over :reserved when identifier is in both sets" do project = create(:project, identifier: "abc", name: "Alpha Beta Corp") - result = described_class.call([project], in_use_handles: Set["abc"], reserved_handles: Set["abc"]) + result = described_class.call([project], in_use_identifiers: Set["abc"], reserved_identifiers: Set["abc"]) expect(result.first[:error_reason]).to eq(:in_use) end it "prefers :too_long over :in_use when identifier is also too long" do - # "toolong" is 7 chars (> HANDLE_MAX_LENGTH=5) and alphanumeric — too_long wins - project = create(:project, identifier: "toolong", name: "Too Long Handle") - result = described_class.call([project], in_use_handles: Set["toolong"]) + # "toolong" is 7 chars (> IDENTIFIER_MAX_LENGTH=5) and alphanumeric — too_long wins + project = create(:project, identifier: "toolong", name: "Too Long Identifier") + result = described_class.call([project], in_use_identifiers: Set["toolong"]) expect(result.first[:error_reason]).to eq(:too_long) end end - describe ".suggest_handle" do - it "produces the same handle as .call for the same name" do + describe ".suggest_identifier" do + it "produces the same identifier as .call for the same name" do project = build_stubbed(:project, name: "Alpha Beta", identifier: "alpha-beta") - batch_result = described_class.call([project]).first[:suggested_handle] - single_result = described_class.suggest_handle("Alpha Beta") + batch_result = described_class.call([project]).first[:suggested_identifier] + single_result = described_class.suggest_identifier("Alpha Beta") expect(single_result).to eq(batch_result) end end From e09288dd6a4bdf148ee0590eefc09f43e8e67443 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 16:56:10 +0300 Subject: [PATCH 133/435] Address code review: rename column, remove padding, split constants, extract error_reason --- .../identifier_autofix_section_component.rb | 9 ++- .../identifier_autofix/preview_query.rb | 25 +++++-- ...project_identifier_suggestion_generator.rb | 29 ++------- config/locales/en.yml | 2 +- ...entifier_autofix_section_component_spec.rb | 2 +- .../identifier_autofix/preview_query_spec.rb | 19 +++++- ...ct_identifier_suggestion_generator_spec.rb | 65 ++++--------------- 7 files changed, 61 insertions(+), 90 deletions(-) diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb index b1c16900d93..75ac28dd0db 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb @@ -53,12 +53,11 @@ module WorkPackages end # Produces a realistic-looking example work package ID for the preview table. - # The sequence number is derived deterministically from the handle so it looks + # The sequence number is derived deterministically from the identifier so it looks # varied across projects but is stable across renders. Range: 1–500. - # Single-digit numbers are zero-padded ("FP-07"), two/three digits are not ("FP-42"). - def sample_wp_id(handle) - n = (handle.bytes.sum % 500) + 1 - "#{handle}-#{format('%02d', n)}" + def sample_wp_id(identifier) + n = (identifier.bytes.sum % 500) + 1 + "#{identifier}-#{n}" end end end diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb index 475516150e3..d32c1ea8144 100644 --- a/app/services/work_packages/identifier_autofix/preview_query.rb +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -47,24 +47,37 @@ module WorkPackages reserved_identifiers: ) - Result.new(projects_data: suggestions, total_count: total) + projects_data = suggestions.map do |entry| + entry.merge(error_reason: error_reason(entry[:current_identifier])) + end + + Result.new(projects_data:, total_count: total) end private - # FIXME: Replace WHERE clause with: - # Project.where.not(id: OldProjectIdentifier.where(current: true).select(:project_id)) - # once all valid identifiers have been migrated to handle rows. def problematic_scope @problematic_scope ||= Project.where( "length(identifier) > ? OR identifier ~ ?", - WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator::IDENTIFIER_MAX_LENGTH, + ProjectIdentifierSuggestionGenerator::MAX_IDENTIFIER_LENGTH, "[^a-zA-Z0-9]" ) end + def error_reason(identifier) + if identifier.length > ProjectIdentifierSuggestionGenerator::MAX_IDENTIFIER_LENGTH + :too_long + elsif identifier.match?(/[^a-zA-Z0-9]/) + :special_characters + elsif in_use_identifiers.include?(identifier) + :in_use + elsif reserved_identifiers.include?(identifier) + :reserved + end + end + def in_use_identifiers - Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set + @in_use_identifiers ||= Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set end def reserved_identifiers diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index 3738a921b4b..a61e0de93ec 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -37,15 +37,9 @@ module WorkPackages # projects produce the same acronym, a numeric suffix resolves the collision # ("SC", "SC2", "SC3", …). # - # Each result entry includes an error_reason classifying why the project's - # current identifier is problematic: - # - :too_long — identifier length exceeds IDENTIFIER_MAX_LENGTH - # - :special_characters — identifier contains characters outside [a-zA-Z0-9] - # - :in_use — identifier is another project's active identifier - # - :reserved — identifier appears in another project's identifier history - # class ProjectIdentifierSuggestionGenerator - IDENTIFIER_MAX_LENGTH = 5 + MAX_IDENTIFIER_LENGTH = 10 + DEFAULT_IDENTIFIER_BASE_LENGTH = 5 SINGLE_WORD_LENGTH = 3 FALLBACK_IDENTIFIER = "PROJ" SUFFIX_LIMIT = 10_000 @@ -82,8 +76,7 @@ module WorkPackages { project:, current_identifier: project.identifier, - suggested_identifier: identifier, - error_reason: error_reason(project.identifier, reserved_identifiers:, in_use_identifiers:) + suggested_identifier: identifier } end end @@ -112,7 +105,7 @@ module WorkPackages end.join return FALLBACK_IDENTIFIER if acronym.empty? - acronym.slice(0, IDENTIFIER_MAX_LENGTH) + acronym.slice(0, DEFAULT_IDENTIFIER_BASE_LENGTH) end def unique_identifier(base, used_identifiers) @@ -124,25 +117,13 @@ module WorkPackages if counter > SUFFIX_LIMIT suffix = counter.to_s - candidate = "#{base.slice(0, IDENTIFIER_MAX_LENGTH - suffix.length)}#{suffix}" + candidate = "#{base.slice(0, DEFAULT_IDENTIFIER_BASE_LENGTH - suffix.length)}#{suffix}" break candidate unless used_identifiers.include?(candidate) counter += 1 end end - def error_reason(identifier, reserved_identifiers:, in_use_identifiers:) - if identifier.length > IDENTIFIER_MAX_LENGTH - :too_long - elsif identifier.match?(/[^a-zA-Z0-9]/) - :special_characters - elsif in_use_identifiers.include?(identifier) - :in_use - elsif reserved_identifiers.include?(identifier) - :reserved - end - end - def combined_identifiers(*sets) sets.reduce(Set.new, :merge) end diff --git a/config/locales/en.yml b/config/locales/en.yml index c023f0ef2da..16518f2525b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -394,7 +394,7 @@ en: box_header: label_project: Project label_previous_identifier: Previous identifier - label_autofixed_suggestion: Autofixed suggestion + label_autofixed_suggestion: Future identifier label_example_work_package_id: Example work package ID autofix_preview: error_too_long: Has to be fewer than 5 characters diff --git a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb index 5ddab53698f..b42137f4876 100644 --- a/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_autofix_section_component_spec.rb @@ -76,7 +76,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent, it "shows a realistic example work package ID" do render_inline(component) - # Numbers are deterministic from the handle's byte sum; format is handle + zero-padded number. + # Numbers are deterministic from the identifier's byte sum. expect(page).to have_text("FP-151") # "FP".bytes.sum % 500 + 1 = 151 expect(page).to have_text("VLNP-321") # "VLNP".bytes.sum % 500 + 1 = 321 end diff --git a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb index fc51ae8938c..3292bbaaf71 100644 --- a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb +++ b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb @@ -95,10 +95,27 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do end end - it "returns Result entries shaped like generator output" do + it "returns Result entries with project, current_identifier, suggested_identifier, and error_reason" do create_problematic_project(name: "Alpha Beta", identifier: "alpha-beta") entry = result.projects_data.first expect(entry).to include(:project, :current_identifier, :suggested_identifier, :error_reason) end + + describe "error_reason classification" do + it "assigns :too_long when identifier length exceeds MAX_IDENTIFIER_LENGTH" do + create_problematic_project(name: "Test", identifier: "averylongidentifier") + expect(result.projects_data.first[:error_reason]).to eq(:too_long) + end + + it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do + create_problematic_project(name: "Test", identifier: "ab-c") + expect(result.projects_data.first[:error_reason]).to eq(:special_characters) + end + + it "assigns :too_long (priority) when identifier is both too long and has special chars" do + create_problematic_project(name: "Test", identifier: "my-very-long-identifier") + expect(result.projects_data.first[:error_reason]).to eq(:too_long) + end + end end diff --git a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb index fd492fcfb65..fa650381d70 100644 --- a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb @@ -46,21 +46,18 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener expect(result.size).to eq(1) expect(result.first[:project]).to eq(project) expect(result.first[:current_identifier]).to eq("verylongidentifier") - expect(result.first[:error_reason]).to eq(:too_long) expect(result.first[:suggested_identifier]).to be_present - expect(result.first[:suggested_identifier].length).to be <= described_class::IDENTIFIER_MAX_LENGTH + expect(result.first[:suggested_identifier].length).to be <= described_class::DEFAULT_IDENTIFIER_BASE_LENGTH end end context "when a project has a special-character identifier" do - # "fs" is 2 chars (≤ IDENTIFIER_MAX_LENGTH) but contains no special chars; - # use "f-s" (3 chars ≤ IDENTIFIER_MAX_LENGTH) to trigger :special_characters. shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") } - it "returns a suggestion entry with error_reason :special_characters" do + it "returns a suggestion entry with a suggested_identifier" do result = described_class.call([project]) expect(result.size).to eq(1) - expect(result.first[:error_reason]).to eq(:special_characters) + expect(result.first[:suggested_identifier]).to eq("FS") end end @@ -94,7 +91,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener "Social media marketing" => "SMM", "Arcanos (mobile-web-app)" => "AMWA", "Flight Planning Training" => "FPT", - "A B C D E F G H I J K" => "ABCDE", # truncated to IDENTIFIER_MAX_LENGTH (5) + "A B C D E F G H I J K" => "ABCDE", # truncated to DEFAULT_IDENTIFIER_BASE_LENGTH (5) "Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"] "étude de cas" => "EDC", # Unicode: é→E via transliteration # Non-Latin scripts have no transliteration entries (I18n.transliterate → "?"). @@ -122,11 +119,11 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener expect(described_class.call([p1, p2, p3]).pluck(:suggested_identifier)).to contain_exactly("SC", "SC2", "SC3") end - it "trims the base to fit within IDENTIFIER_MAX_LENGTH when adding a suffix" do + it "trims the base to fit within DEFAULT_IDENTIFIER_BASE_LENGTH when adding a suffix" do p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier) - expect(identifiers.all? { it.length <= described_class::IDENTIFIER_MAX_LENGTH }).to be true + expect(identifiers.all? { it.length <= described_class::DEFAULT_IDENTIFIER_BASE_LENGTH }).to be true expect(identifiers.uniq.size).to eq(2) end @@ -139,49 +136,6 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener end end - describe "error reason assignment" do - it "assigns :too_long when identifier length exceeds IDENTIFIER_MAX_LENGTH" do - project = create(:project, identifier: "verylongidentifier", name: "Test") - expect(described_class.call([project]).first[:error_reason]).to eq(:too_long) - end - - it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do - project = create(:project, identifier: "ab-c", name: "Test") - expect(described_class.call([project]).first[:error_reason]).to eq(:special_characters) - end - - it "assigns :too_long (priority) when identifier is both too long and has special chars" do - project = create(:project, identifier: "my-very-long-identifier", name: "Test") - expect(described_class.call([project]).first[:error_reason]).to eq(:too_long) - end - - it "assigns :in_use when identifier is another project's active identifier" do - # "abc" is valid (lowercase alphanumeric, ≤ 5 chars, no special chars) - project = create(:project, identifier: "abc", name: "Alpha Beta Corp") - result = described_class.call([project], in_use_identifiers: Set["abc"]) - expect(result.first[:error_reason]).to eq(:in_use) - end - - it "assigns :reserved when identifier appears in historical identifiers" do - project = create(:project, identifier: "abc", name: "Alpha Beta Corp") - result = described_class.call([project], reserved_identifiers: Set["abc"]) - expect(result.first[:error_reason]).to eq(:reserved) - end - - it "prefers :in_use over :reserved when identifier is in both sets" do - project = create(:project, identifier: "abc", name: "Alpha Beta Corp") - result = described_class.call([project], in_use_identifiers: Set["abc"], reserved_identifiers: Set["abc"]) - expect(result.first[:error_reason]).to eq(:in_use) - end - - it "prefers :too_long over :in_use when identifier is also too long" do - # "toolong" is 7 chars (> IDENTIFIER_MAX_LENGTH=5) and alphanumeric — too_long wins - project = create(:project, identifier: "toolong", name: "Too Long Identifier") - result = described_class.call([project], in_use_identifiers: Set["toolong"]) - expect(result.first[:error_reason]).to eq(:too_long) - end - end - describe ".suggest_identifier" do it "produces the same identifier as .call for the same name" do project = build_stubbed(:project, name: "Alpha Beta", identifier: "alpha-beta") @@ -190,4 +144,11 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener expect(single_result).to eq(batch_result) end end + + describe ".call result shape" do + it "does not include error_reason (that is PreviewQuery's concern)" do + project = create(:project, identifier: "ab-c", name: "Test") + expect(described_class.call([project]).first).not_to have_key(:error_reason) + end + end end From f2b23e0d15e71db709266982399907bb39d5aef6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 18:01:11 +0300 Subject: [PATCH 134/435] Fix identifier generator to match semantic identifier spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace numeric suffix collision strategy with progressive acronym widening ("SC" → "STC" → "STCO" instead of "SC" → "SC2" → "SC3") - Allow underscores in identifiers (fix regex in PreviewQuery) - Enforce identifiers must start with a letter (strip leading digits) - Use DEFAULT_IDENTIFIER_BASE_LENGTH (5) for initial generation with MAX_IDENTIFIER_LENGTH (10) as expansion ceiling for collisions - Enforce MIN_IDENTIFIER_LENGTH (2) for generated identifiers - Expand single-word identifiers on collision ("BAN" → "BANA" → "BANAN") --- .../identifier_autofix/preview_query.rb | 4 +- ...project_identifier_suggestion_generator.rb | 167 ++++++++++++++---- .../identifier_autofix/preview_query_spec.rb | 9 + ...ct_identifier_suggestion_generator_spec.rb | 82 ++++++--- 4 files changed, 206 insertions(+), 56 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb index d32c1ea8144..d7fa34de882 100644 --- a/app/services/work_packages/identifier_autofix/preview_query.rb +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -60,14 +60,14 @@ module WorkPackages @problematic_scope ||= Project.where( "length(identifier) > ? OR identifier ~ ?", ProjectIdentifierSuggestionGenerator::MAX_IDENTIFIER_LENGTH, - "[^a-zA-Z0-9]" + "[^a-zA-Z0-9_]" ) end def error_reason(identifier) if identifier.length > ProjectIdentifierSuggestionGenerator::MAX_IDENTIFIER_LENGTH :too_long - elsif identifier.match?(/[^a-zA-Z0-9]/) + elsif identifier.match?(/[^a-zA-Z0-9_]/) :special_characters elsif in_use_identifiers.include?(identifier) :in_use diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index a61e0de93ec..a25e1a30c02 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -30,17 +30,40 @@ module WorkPackages module IdentifierAutofix - # Generates a short uppercase acronym suggestion for each given project. + # Generates a short uppercase semantic identifier for each project. # - # The suggestion is derived from the project name: taking the first letter of - # each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two - # projects produce the same acronym, a numeric suffix resolves the collision - # ("SC", "SC2", "SC3", …). + # Identifiers are 2–10 uppercase alphanumeric characters that always start + # with a letter. + # + # == Algorithm + # + # *Multi-word names* use word initials, truncated to +DEFAULT_IDENTIFIER_BASE_LENGTH+ (5): + # "Flight Planning Algorithm" → "FPA" + # "A B C D E F G H I J K" → "ABCDE" + # + # *Single-word names* use the first +SINGLE_WORD_BASE_LENGTH+ (3) characters: + # "Banana" → "BAN" + # + # *Accented characters* are transliterated ("Cécile" → "CEC"). + # *Non-Latin scripts* that have no transliteration fall back to "PROJ". + # + # == Collision resolution + # + # When a candidate is already taken, the identifier is progressively widened + # with more characters from the name, up to +MAX_IDENTIFIER_LENGTH+ (10): + # + # Multi-word: "SC" → "STC" → "STCO" → "STRCO" → … → "STREACOMMU" + # Single-word: "BAN" → "BANA" → "BANAN" → "BANANA" + # Initials: "ABCDE" → "ABCDEF" → … → "ABCDEFGHIJ" + # + # If all expansion candidates are exhausted, a numeric suffix is appended + # as a last resort ("GO" → "GO2"). # class ProjectIdentifierSuggestionGenerator MAX_IDENTIFIER_LENGTH = 10 DEFAULT_IDENTIFIER_BASE_LENGTH = 5 - SINGLE_WORD_LENGTH = 3 + MIN_IDENTIFIER_LENGTH = 2 + SINGLE_WORD_BASE_LENGTH = 3 FALLBACK_IDENTIFIER = "PROJ" SUFFIX_LIMIT = 10_000 @@ -59,8 +82,9 @@ module WorkPackages end def suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new) - base = identifier_from_name(name) - unique_identifier(base, combined_identifiers(reserved_identifiers, in_use_identifiers)) + used = combined_identifiers(reserved_identifiers, in_use_identifiers) + candidates = identifier_candidates(name) + find_unique(candidates, used) end private @@ -69,8 +93,8 @@ module WorkPackages used_identifiers = combined_identifiers(reserved_identifiers, in_use_identifiers) projects.map do |project| - base = identifier_from_name(project.name) - identifier = unique_identifier(base, used_identifiers) + candidates = identifier_candidates(project.name) + identifier = find_unique(candidates, used_identifiers) used_identifiers << identifier { @@ -81,44 +105,121 @@ module WorkPackages end end - def identifier_from_name(name) + # Returns an ordered list of progressively longer identifier candidates + # derived from the project name. The first unique candidate wins. + def identifier_candidates(name) + words = transliterated_words(name) + return [FALLBACK_IDENTIFIER] if words.empty? + + candidates = words.size == 1 ? single_word_candidates(words.first) : multi_word_candidates(words) + candidates = candidates.filter_map { ensure_starts_with_letter(it) } + candidates = candidates.select { it.length >= MIN_IDENTIFIER_LENGTH } + candidates.presence || [FALLBACK_IDENTIFIER] + end + + # Splits a name into words and transliterates each, returning only words + # that contain at least one ASCII-alphanumeric character. + def transliterated_words(name) # Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside # their word rather than treated as separators by the ASCII-only [a-zA-Z]. - words = name.to_s.scan(/[[:alpha:][:digit:]]+/) - return FALLBACK_IDENTIFIER if words.empty? - - words.size == 1 ? identifier_from_single_word(words.first) : identifier_from_words(words) + raw_words = name.to_s.scan(/[[:alpha:][:digit:]]+/) + raw_words.filter_map do |word| + t = I18n.with_locale(:en) { I18n.transliterate(word) } + clean = t.scan(/[A-Za-z0-9]/).join + clean.presence + end end - def identifier_from_single_word(word) - # e.g. "Banana" → "BAN", "Kiwi" → "KIW", "日本語" → FALLBACK_IDENTIFIER - t = I18n.with_locale(:en) { I18n.transliterate(word) } - chars = t.scan(/[A-Za-z0-9]/).first(SINGLE_WORD_LENGTH).map(&:upcase).join - chars.empty? ? FALLBACK_IDENTIFIER : chars + # "Banana" → ["BAN", "BANA", "BANAN", "BANANA"] + def single_word_candidates(word) + chars = word.upcase + max_len = [chars.length, MAX_IDENTIFIER_LENGTH].min + return [] if max_len < MIN_IDENTIFIER_LENGTH + + start_len = SINGLE_WORD_BASE_LENGTH.clamp(MIN_IDENTIFIER_LENGTH, max_len) + (start_len..max_len).map { |len| chars[0, len] } end - def identifier_from_words(words) - # Multi-word names: take initials (first letter of each word), truncated. - acronym = words.filter_map do |word| - ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0] - ch if ch&.match?(/\A[A-Z0-9]\z/) - end.join - return FALLBACK_IDENTIFIER if acronym.empty? + # "Stream Communicator" → ["SC", "STC", "STCO", "STRCO", …] + # "A B C D E F G H I J K" → ["ABCDE", "ABCDEF", …, "ABCDEFGHIJ"] + # + # Phase 1: Truncate initials to DEFAULT_IDENTIFIER_BASE_LENGTH, then + # progressively include more initials up to MAX_IDENTIFIER_LENGTH. + # Phase 2: If still room, expand words beyond single chars. + def multi_word_candidates(words) + clean_words = words.map { |w| w.upcase.chars } + candidates = initial_truncation_candidates(clean_words) - acronym.slice(0, DEFAULT_IDENTIFIER_BASE_LENGTH) + return candidates if candidates.last&.length.to_i >= MAX_IDENTIFIER_LENGTH + + chars_per_word = clean_words.map { 1 } + append_expansion_candidates(candidates, clean_words, chars_per_word) + candidates end - def unique_identifier(base, used_identifiers) - return base unless used_identifiers.include?(base) + # Phase 1: progressively longer slices of the initials string, + # starting at DEFAULT_IDENTIFIER_BASE_LENGTH. + def initial_truncation_candidates(clean_words) + initials = clean_words.map(&:first).join[0, MAX_IDENTIFIER_LENGTH] + start = [DEFAULT_IDENTIFIER_BASE_LENGTH, initials.length].min + (start..initials.length).map { |len| initials[0, len] } + end + # Phase 2: pull more characters from each word left-to-right. + def append_expansion_candidates(candidates, clean_words, chars_per_word) + expand_word_candidates(clean_words, chars_per_word).each do |c| + candidates << c unless candidates.include?(c) + break if c.length >= MAX_IDENTIFIER_LENGTH + end + end + + def expand_word_candidates(clean_words, chars_per_word) + candidates = [] + + loop do + candidate = build_candidate(clean_words, chars_per_word) + candidates << candidate unless candidates.include?(candidate) + break if candidate.length >= MAX_IDENTIFIER_LENGTH + + expandable = clean_words.index.with_index { |cw, i| chars_per_word[i] < cw.length } + break unless expandable + + chars_per_word[expandable] += 1 + end + + candidates + end + + def build_candidate(clean_words, chars_per_word) + clean_words.each_with_index.map { |cw, i| cw.first(chars_per_word[i]).join }.join[0, MAX_IDENTIFIER_LENGTH] + end + + # Strips leading digits so identifiers always start with a letter. + # Returns nil if nothing remains after stripping. + def ensure_starts_with_letter(candidate) + stripped = candidate.sub(/\A\d+/, "") + stripped.presence + end + + # Iterates through expansion candidates, then falls back to numeric suffix. + def find_unique(candidates, used_identifiers) + candidates.each do |candidate| + return candidate unless used_identifiers.include?(candidate) + end + + base = candidates.last || FALLBACK_IDENTIFIER + numeric_suffix_fallback(base, used_identifiers) + end + + def numeric_suffix_fallback(base, used_identifiers) counter = 2 loop do raise "Could not find a unique identifier for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ if counter > SUFFIX_LIMIT - suffix = counter.to_s - candidate = "#{base.slice(0, DEFAULT_IDENTIFIER_BASE_LENGTH - suffix.length)}#{suffix}" - break candidate unless used_identifiers.include?(candidate) + suffix = counter.to_s + candidate = "#{base[0, MAX_IDENTIFIER_LENGTH - suffix.length]}#{suffix}" + return candidate unless used_identifiers.include?(candidate) counter += 1 end diff --git a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb index 3292bbaaf71..5b4e99fbd3e 100644 --- a/spec/services/work_packages/identifier_autofix/preview_query_spec.rb +++ b/spec/services/work_packages/identifier_autofix/preview_query_spec.rb @@ -52,6 +52,15 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do end end + context "when a project has underscores in its identifier" do + before { create_valid_project(name: "My Project", identifier: "my_proj") } + + it "does not flag it as problematic" do + expect(result.total_count).to eq(0) + expect(result.projects_data).to be_empty + end + end + context "when there are fewer than DISPLAY_COUNT problematic projects" do let!(:problematic) do [ diff --git a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb index fa650381d70..49a2bcca4fd 100644 --- a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb @@ -47,7 +47,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener expect(result.first[:project]).to eq(project) expect(result.first[:current_identifier]).to eq("verylongidentifier") expect(result.first[:suggested_identifier]).to be_present - expect(result.first[:suggested_identifier].length).to be <= described_class::DEFAULT_IDENTIFIER_BASE_LENGTH + expect(result.first[:suggested_identifier].length).to be <= described_class::MAX_IDENTIFIER_LENGTH end end @@ -70,34 +70,35 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener expect(identifiers.uniq.size).to eq(identifiers.size) end - it "appends a numeric suffix to resolve conflicts" do + it "resolves conflicts by widening the acronym, not numeric suffixes" do identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier) expect(identifiers).to include("SC") - expect(identifiers.any? { it.match?(/\ASC\d+\z/) }).to be true + # Second project expands to "STC" (Stream → ST, Channel → C) instead of "SC2" + expect(identifiers).to include("STC") end end end describe "identifier generation from project name" do { - # Single-word names: first SINGLE_WORD_LENGTH (3) transliterated chars + # Single-word names: first SINGLE_WORD_BASE_LENGTH (3) transliterated chars "Banana" => "BAN", "Kiwi" => "KIW", "Strawberry" => "STR", - "Cécile" => "CEC", # single word with accented letter - # Multi-word names: initials, truncated to IDENTIFIER_MAX_LENGTH (5) + "Cécile" => "CEC", + # Multi-word names: initials (truncated to DEFAULT_IDENTIFIER_BASE_LENGTH = 5) "Flight Planning Algorithm" => "FPA", "Fly & Sky" => "FS", "Social media marketing" => "SMM", "Arcanos (mobile-web-app)" => "AMWA", "Flight Planning Training" => "FPT", - "A B C D E F G H I J K" => "ABCDE", # truncated to DEFAULT_IDENTIFIER_BASE_LENGTH (5) - "Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"] - "étude de cas" => "EDC", # Unicode: é→E via transliteration - # Non-Latin scripts have no transliteration entries (I18n.transliterate → "?"). - # All initials are dropped and the name falls back to FALLBACK_IDENTIFIER. - "日本語プロジェクト" => "PROJ", # Japanese: every initial → "?" → fallback - "Plan 日本" => "P" # Mixed: Latin "P" survives; "日" is dropped + "A B C D E F G H I J K" => "ABCDE", + "Cécile Martin" => "CM", + "étude de cas" => "EDC", + # Non-Latin scripts: every initial → "?" → fallback + "日本語プロジェクト" => "PROJ", + # Mixed: only "Plan" survives transliteration → single-word → starts at 3 chars + "Plan 日本" => "PLA" }.each do |project_name, expected_identifier| it "generates '#{expected_identifier}' from '#{project_name}'" do project = create(:project, identifier: "bad-id", name: project_name) @@ -106,33 +107,72 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener end end - describe "unique_identifier conflict resolution" do + describe "must start with a letter" do + it "strips leading digits from generated identifiers" do + project = create(:project, identifier: "bad-id", name: "3D Printing Lab") + result = described_class.call([project]).first[:suggested_identifier] + expect(result).to match(/\A[A-Z]/) + end + + it "falls back to PROJ for all-digit names" do + project = create(:project, identifier: "bad-id", name: "123 456") + result = described_class.call([project]).first[:suggested_identifier] + expect(result).to eq("PROJ") + end + end + + describe "minimum identifier length" do + it "never generates identifiers shorter than MIN_IDENTIFIER_LENGTH" do + # Single letter word — too short on its own + project = create(:project, identifier: "bad-id", name: "A") + result = described_class.call([project]).first[:suggested_identifier] + expect(result.length).to be >= described_class::MIN_IDENTIFIER_LENGTH + end + end + + describe "collision resolution by widening" do it "uses the base identifier when not yet taken" do project = create(:project, identifier: "sc-app", name: "Stream Communicator") expect(described_class.call([project]).first[:suggested_identifier]).to eq("SC") end - it "increments the suffix until unique" do + it "widens the acronym instead of appending numeric suffixes" do p1 = create(:project, identifier: "sc-a", name: "Stream Communicator") p2 = create(:project, identifier: "sc-b", name: "Stream Channel") p3 = create(:project, identifier: "sc-c", name: "Something Cool") - expect(described_class.call([p1, p2, p3]).pluck(:suggested_identifier)).to contain_exactly("SC", "SC2", "SC3") + identifiers = described_class.call([p1, p2, p3]).pluck(:suggested_identifier) + expect(identifiers).to contain_exactly("SC", "STC", "SOC") end - it "trims the base to fit within DEFAULT_IDENTIFIER_BASE_LENGTH when adding a suffix" do + it "expands single-word identifiers on collision" do + p1 = create(:project, identifier: "bad-a", name: "Banana") + p2 = create(:project, identifier: "bad-b", name: "Banking") + identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier) + # Both start as "BAN"; second expands to "BANK" + expect(identifiers).to contain_exactly("BAN", "BANK") + end + + it "keeps all identifiers within MAX_IDENTIFIER_LENGTH" do p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier) - expect(identifiers.all? { it.length <= described_class::DEFAULT_IDENTIFIER_BASE_LENGTH }).to be true + expect(identifiers.all? { it.length <= described_class::MAX_IDENTIFIER_LENGTH }).to be true expect(identifiers.uniq.size).to eq(2) end it "does not suggest an identifier that is already in use (pre-seeded collision)" do - # "SC" is pre-seeded as an in-use identifier; the generator must skip it and use "SC2". project = create(:project, identifier: "sc-app", name: "Stream Communicator") result = described_class.call([project], in_use_identifiers: Set["SC"]) - expect(result.first[:suggested_identifier]).not_to eq("SC") - expect(result.first[:suggested_identifier]).to match(/\ASC\d+\z/) # e.g. "SC2" + # "SC" is taken, so it widens to "STC" (Stream → ST, Communicator → C) + expect(result.first[:suggested_identifier]).to eq("STC") + end + + it "falls back to numeric suffix only when all expansion candidates are exhausted" do + # Reserve all expansion candidates for "Go" (a 2-char word) + project = create(:project, identifier: "bad-id", name: "Go") + result = described_class.call([project], in_use_identifiers: Set["GO"]) + # "GO" is taken, no further expansion possible, so numeric suffix + expect(result.first[:suggested_identifier]).to eq("GO2") end end From 0053b140a9874fca84ab65f886dbc394cd51a1db Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 18:06:14 +0300 Subject: [PATCH 135/435] Consolidate identifier length constants into IDENTIFIER_LENGTH hash Group the four related length constants (min, max, base, single_word) into a single frozen hash for better locality and fewer top-level names. --- .../identifier_autofix/preview_query.rb | 4 +- ...project_identifier_suggestion_generator.rb | 39 +++++++++---------- ...ct_identifier_suggestion_generator_spec.rb | 10 ++--- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/preview_query.rb b/app/services/work_packages/identifier_autofix/preview_query.rb index d7fa34de882..3c478b5f152 100644 --- a/app/services/work_packages/identifier_autofix/preview_query.rb +++ b/app/services/work_packages/identifier_autofix/preview_query.rb @@ -59,13 +59,13 @@ module WorkPackages def problematic_scope @problematic_scope ||= Project.where( "length(identifier) > ? OR identifier ~ ?", - ProjectIdentifierSuggestionGenerator::MAX_IDENTIFIER_LENGTH, + ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max], "[^a-zA-Z0-9_]" ) end def error_reason(identifier) - if identifier.length > ProjectIdentifierSuggestionGenerator::MAX_IDENTIFIER_LENGTH + if identifier.length > ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max] :too_long elsif identifier.match?(/[^a-zA-Z0-9_]/) :special_characters diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index a25e1a30c02..8b86155a188 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -37,11 +37,11 @@ module WorkPackages # # == Algorithm # - # *Multi-word names* use word initials, truncated to +DEFAULT_IDENTIFIER_BASE_LENGTH+ (5): + # *Multi-word names* use word initials, truncated to +IDENTIFIER_LENGTH[:base]+ (5): # "Flight Planning Algorithm" → "FPA" # "A B C D E F G H I J K" → "ABCDE" # - # *Single-word names* use the first +SINGLE_WORD_BASE_LENGTH+ (3) characters: + # *Single-word names* use the first +IDENTIFIER_LENGTH[:single_word]+ (3) characters: # "Banana" → "BAN" # # *Accented characters* are transliterated ("Cécile" → "CEC"). @@ -50,7 +50,7 @@ module WorkPackages # == Collision resolution # # When a candidate is already taken, the identifier is progressively widened - # with more characters from the name, up to +MAX_IDENTIFIER_LENGTH+ (10): + # with more characters from the name, up to +IDENTIFIER_LENGTH[:max]+ (10): # # Multi-word: "SC" → "STC" → "STCO" → "STRCO" → … → "STREACOMMU" # Single-word: "BAN" → "BANA" → "BANAN" → "BANANA" @@ -60,10 +60,7 @@ module WorkPackages # as a last resort ("GO" → "GO2"). # class ProjectIdentifierSuggestionGenerator - MAX_IDENTIFIER_LENGTH = 10 - DEFAULT_IDENTIFIER_BASE_LENGTH = 5 - MIN_IDENTIFIER_LENGTH = 2 - SINGLE_WORD_BASE_LENGTH = 3 + IDENTIFIER_LENGTH = { min: 2, max: 10, base: 5, single_word: 3 }.freeze FALLBACK_IDENTIFIER = "PROJ" SUFFIX_LIMIT = 10_000 @@ -113,7 +110,7 @@ module WorkPackages candidates = words.size == 1 ? single_word_candidates(words.first) : multi_word_candidates(words) candidates = candidates.filter_map { ensure_starts_with_letter(it) } - candidates = candidates.select { it.length >= MIN_IDENTIFIER_LENGTH } + candidates = candidates.select { it.length >= IDENTIFIER_LENGTH[:min] } candidates.presence || [FALLBACK_IDENTIFIER] end @@ -133,24 +130,24 @@ module WorkPackages # "Banana" → ["BAN", "BANA", "BANAN", "BANANA"] def single_word_candidates(word) chars = word.upcase - max_len = [chars.length, MAX_IDENTIFIER_LENGTH].min - return [] if max_len < MIN_IDENTIFIER_LENGTH + max_len = [chars.length, IDENTIFIER_LENGTH[:max]].min + return [] if max_len < IDENTIFIER_LENGTH[:min] - start_len = SINGLE_WORD_BASE_LENGTH.clamp(MIN_IDENTIFIER_LENGTH, max_len) + start_len = IDENTIFIER_LENGTH[:single_word].clamp(IDENTIFIER_LENGTH[:min], max_len) (start_len..max_len).map { |len| chars[0, len] } end # "Stream Communicator" → ["SC", "STC", "STCO", "STRCO", …] # "A B C D E F G H I J K" → ["ABCDE", "ABCDEF", …, "ABCDEFGHIJ"] # - # Phase 1: Truncate initials to DEFAULT_IDENTIFIER_BASE_LENGTH, then - # progressively include more initials up to MAX_IDENTIFIER_LENGTH. + # Phase 1: Truncate initials to IDENTIFIER_LENGTH[:base], then + # progressively include more initials up to IDENTIFIER_LENGTH[:max]. # Phase 2: If still room, expand words beyond single chars. def multi_word_candidates(words) clean_words = words.map { |w| w.upcase.chars } candidates = initial_truncation_candidates(clean_words) - return candidates if candidates.last&.length.to_i >= MAX_IDENTIFIER_LENGTH + return candidates if candidates.last&.length.to_i >= IDENTIFIER_LENGTH[:max] chars_per_word = clean_words.map { 1 } append_expansion_candidates(candidates, clean_words, chars_per_word) @@ -158,10 +155,10 @@ module WorkPackages end # Phase 1: progressively longer slices of the initials string, - # starting at DEFAULT_IDENTIFIER_BASE_LENGTH. + # starting at IDENTIFIER_LENGTH[:base]. def initial_truncation_candidates(clean_words) - initials = clean_words.map(&:first).join[0, MAX_IDENTIFIER_LENGTH] - start = [DEFAULT_IDENTIFIER_BASE_LENGTH, initials.length].min + initials = clean_words.map(&:first).join[0, IDENTIFIER_LENGTH[:max]] + start = [IDENTIFIER_LENGTH[:base], initials.length].min (start..initials.length).map { |len| initials[0, len] } end @@ -169,7 +166,7 @@ module WorkPackages def append_expansion_candidates(candidates, clean_words, chars_per_word) expand_word_candidates(clean_words, chars_per_word).each do |c| candidates << c unless candidates.include?(c) - break if c.length >= MAX_IDENTIFIER_LENGTH + break if c.length >= IDENTIFIER_LENGTH[:max] end end @@ -179,7 +176,7 @@ module WorkPackages loop do candidate = build_candidate(clean_words, chars_per_word) candidates << candidate unless candidates.include?(candidate) - break if candidate.length >= MAX_IDENTIFIER_LENGTH + break if candidate.length >= IDENTIFIER_LENGTH[:max] expandable = clean_words.index.with_index { |cw, i| chars_per_word[i] < cw.length } break unless expandable @@ -191,7 +188,7 @@ module WorkPackages end def build_candidate(clean_words, chars_per_word) - clean_words.each_with_index.map { |cw, i| cw.first(chars_per_word[i]).join }.join[0, MAX_IDENTIFIER_LENGTH] + clean_words.each_with_index.map { |cw, i| cw.first(chars_per_word[i]).join }.join[0, IDENTIFIER_LENGTH[:max]] end # Strips leading digits so identifiers always start with a letter. @@ -218,7 +215,7 @@ module WorkPackages if counter > SUFFIX_LIMIT suffix = counter.to_s - candidate = "#{base[0, MAX_IDENTIFIER_LENGTH - suffix.length]}#{suffix}" + candidate = "#{base[0, IDENTIFIER_LENGTH[:max] - suffix.length]}#{suffix}" return candidate unless used_identifiers.include?(candidate) counter += 1 diff --git a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb index 49a2bcca4fd..460a96a80c8 100644 --- a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb @@ -47,7 +47,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener expect(result.first[:project]).to eq(project) expect(result.first[:current_identifier]).to eq("verylongidentifier") expect(result.first[:suggested_identifier]).to be_present - expect(result.first[:suggested_identifier].length).to be <= described_class::MAX_IDENTIFIER_LENGTH + expect(result.first[:suggested_identifier].length).to be <= described_class::IDENTIFIER_LENGTH[:max] end end @@ -81,12 +81,12 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener describe "identifier generation from project name" do { - # Single-word names: first SINGLE_WORD_BASE_LENGTH (3) transliterated chars + # Single-word names: first IDENTIFIER_LENGTH[:single_word] (3) transliterated chars "Banana" => "BAN", "Kiwi" => "KIW", "Strawberry" => "STR", "Cécile" => "CEC", - # Multi-word names: initials (truncated to DEFAULT_IDENTIFIER_BASE_LENGTH = 5) + # Multi-word names: initials (truncated to IDENTIFIER_LENGTH[:base] = 5) "Flight Planning Algorithm" => "FPA", "Fly & Sky" => "FS", "Social media marketing" => "SMM", @@ -126,7 +126,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener # Single letter word — too short on its own project = create(:project, identifier: "bad-id", name: "A") result = described_class.call([project]).first[:suggested_identifier] - expect(result.length).to be >= described_class::MIN_IDENTIFIER_LENGTH + expect(result.length).to be >= described_class::IDENTIFIER_LENGTH[:min] end end @@ -156,7 +156,7 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J") p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J") identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier) - expect(identifiers.all? { it.length <= described_class::MAX_IDENTIFIER_LENGTH }).to be true + expect(identifiers.all? { it.length <= described_class::IDENTIFIER_LENGTH[:max] }).to be true expect(identifiers.uniq.size).to eq(2) end From 68ef621217c7b2d8dd820ed3100e59b22b395c92 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 18:24:14 +0300 Subject: [PATCH 136/435] Simplify multi-word pipeline, harden digit-stripping, add ordering test - Flatten multi-word candidate generation from 5 methods to 4 by removing unnecessary indirection layers - Apply ensure_starts_with_letter in numeric_suffix_fallback so fallback identifiers also satisfy the starts-with-letter constraint - Add test verifying batch mode assigns identifiers in array order --- ...project_identifier_suggestion_generator.rb | 44 +++++++------------ ...ct_identifier_suggestion_generator_spec.rb | 15 +++++++ 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index 8b86155a188..8932d0fd512 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -140,51 +140,35 @@ module WorkPackages # "Stream Communicator" → ["SC", "STC", "STCO", "STRCO", …] # "A B C D E F G H I J K" → ["ABCDE", "ABCDEF", …, "ABCDEFGHIJ"] # - # Phase 1: Truncate initials to IDENTIFIER_LENGTH[:base], then - # progressively include more initials up to IDENTIFIER_LENGTH[:max]. - # Phase 2: If still room, expand words beyond single chars. + # Starts with initials truncated to IDENTIFIER_LENGTH[:base], progressively + # includes more initials, then expands words beyond single chars. def multi_word_candidates(words) clean_words = words.map { |w| w.upcase.chars } - candidates = initial_truncation_candidates(clean_words) + candidates = initial_candidates(clean_words) - return candidates if candidates.last&.length.to_i >= IDENTIFIER_LENGTH[:max] - - chars_per_word = clean_words.map { 1 } - append_expansion_candidates(candidates, clean_words, chars_per_word) + expand_words_into(candidates, clean_words) if candidates.last.length < IDENTIFIER_LENGTH[:max] candidates end - # Phase 1: progressively longer slices of the initials string, - # starting at IDENTIFIER_LENGTH[:base]. - def initial_truncation_candidates(clean_words) + def initial_candidates(clean_words) initials = clean_words.map(&:first).join[0, IDENTIFIER_LENGTH[:max]] start = [IDENTIFIER_LENGTH[:base], initials.length].min (start..initials.length).map { |len| initials[0, len] } end - # Phase 2: pull more characters from each word left-to-right. - def append_expansion_candidates(candidates, clean_words, chars_per_word) - expand_word_candidates(clean_words, chars_per_word).each do |c| - candidates << c unless candidates.include?(c) - break if c.length >= IDENTIFIER_LENGTH[:max] - end - end - - def expand_word_candidates(clean_words, chars_per_word) - candidates = [] + # Progressively pulls more characters from each word left-to-right. + def expand_words_into(candidates, clean_words) + chars_per_word = clean_words.map { 1 } loop do - candidate = build_candidate(clean_words, chars_per_word) - candidates << candidate unless candidates.include?(candidate) - break if candidate.length >= IDENTIFIER_LENGTH[:max] - expandable = clean_words.index.with_index { |cw, i| chars_per_word[i] < cw.length } break unless expandable chars_per_word[expandable] += 1 + candidate = build_candidate(clean_words, chars_per_word) + candidates << candidate unless candidates.include?(candidate) + break if candidate.length >= IDENTIFIER_LENGTH[:max] end - - candidates end def build_candidate(clean_words, chars_per_word) @@ -192,7 +176,8 @@ module WorkPackages end # Strips leading digits so identifiers always start with a letter. - # Returns nil if nothing remains after stripping. + # For names like "3D Printing Lab", initials "3PL" become "PL". + # This is lossy but acceptable for auto-generated suggestions. def ensure_starts_with_letter(candidate) stripped = candidate.sub(/\A\d+/, "") stripped.presence @@ -209,6 +194,9 @@ module WorkPackages end def numeric_suffix_fallback(base, used_identifiers) + # Ensure the base itself starts with a letter before appending digits. + base = ensure_starts_with_letter(base) || FALLBACK_IDENTIFIER + counter = 2 loop do raise "Could not find a unique identifier for base '#{base}' within #{SUFFIX_LIMIT} attempts" \ diff --git a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb index 460a96a80c8..fd6de992598 100644 --- a/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb +++ b/spec/services/work_packages/identifier_autofix/project_identifier_suggestion_generator_spec.rb @@ -174,6 +174,21 @@ RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGener # "GO" is taken, no further expansion possible, so numeric suffix expect(result.first[:suggested_identifier]).to eq("GO2") end + + it "assigns identifiers in array order — first project claims the base" do + p1 = create(:project, identifier: "bad-a", name: "Stream Communicator") + p2 = create(:project, identifier: "bad-b", name: "Stream Channel") + result = described_class.call([p1, p2]) + + # p1 is first in the array, so it claims "SC"; p2 gets the widened "STC" + expect(result[0][:suggested_identifier]).to eq("SC") + expect(result[1][:suggested_identifier]).to eq("STC") + + # Reversed order: p2 now claims "SC" + reversed = described_class.call([p2, p1]) + expect(reversed[0][:suggested_identifier]).to eq("SC") + expect(reversed[1][:suggested_identifier]).to eq("STC") + end end describe ".suggest_identifier" do From 61e20a0784118178c85566a1427f284531397ede Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 13 Mar 2026 18:34:15 +0300 Subject: [PATCH 137/435] Improve readability: split dense one-liner, clarify mutation and contracts --- .../project_identifier_suggestion_generator.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index 8932d0fd512..aedc202b013 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -172,7 +172,8 @@ module WorkPackages end def build_candidate(clean_words, chars_per_word) - clean_words.each_with_index.map { |cw, i| cw.first(chars_per_word[i]).join }.join[0, IDENTIFIER_LENGTH[:max]] + parts = clean_words.each_with_index.map { |cw, i| cw.first(chars_per_word[i]).join } + parts.join[0, IDENTIFIER_LENGTH[:max]] end # Strips leading digits so identifiers always start with a letter. @@ -184,6 +185,7 @@ module WorkPackages end # Iterates through expansion candidates, then falls back to numeric suffix. + # Candidates are already filtered to start with a letter and meet min length. def find_unique(candidates, used_identifiers) candidates.each do |candidate| return candidate unless used_identifiers.include?(candidate) From a1562bc9a3918f33e4e2961adc975274f3f12607 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 9 Mar 2026 21:04:15 -0300 Subject: [PATCH 138/435] [#69139] Add SprintFilter for work package queries Introduces `OpenProject::Backlogs::SprintFilter`, a query filter that allows work packages to be filtered by `sprint_id`. Registers the filter in the backlogs engine. Adds spec coverage. Co-Authored-By: Claude Opus 4.6 --- .../lib/open_project/backlogs/engine.rb | 5 +- .../open_project/backlogs/sprint_filter.rb | 81 ++++++++++++ .../backlogs/sprint_filter_spec.rb | 123 ++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 modules/backlogs/lib/open_project/backlogs/sprint_filter.rb create mode 100644 modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index e9b0daef535..0d68caeb4e4 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -145,7 +147,7 @@ module OpenProject::Backlogs patch_with_namespace :Versions, :RowComponent config.to_prepare do - next if Versions::BaseContract.included_modules.include?(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) + next if Versions::BaseContract.include?(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) Versions::BaseContract.prepend(OpenProject::Backlogs::Patches::Versions::BaseContractPatch) @@ -224,6 +226,7 @@ module OpenProject::Backlogs ::Queries::Register.register(::Query) do filter OpenProject::Backlogs::WorkPackageFilter + filter OpenProject::Backlogs::SprintFilter select OpenProject::Backlogs::QueryBacklogsSelect end diff --git a/modules/backlogs/lib/open_project/backlogs/sprint_filter.rb b/modules/backlogs/lib/open_project/backlogs/sprint_filter.rb new file mode 100644 index 00000000000..e0469c6c8dd --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/sprint_filter.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject::Backlogs + class SprintFilter < ::Queries::WorkPackages::Filter::WorkPackageFilter + def allowed_values + @allowed_values ||= sprints.pluck(:id, :id).map { |value, id| [value.to_s, id.to_s] } + end + + def available? + scrum_projects_active? && backlogs_enabled? + end + + def type + :list_optional + end + + def self.key + :sprint_id + end + + def human_name + WorkPackage.human_attribute_name(:sprint) + end + + def ar_object_filter? + true + end + + def value_objects + available_sprints = sprints.index_by(&:id) + + values + .filter_map { |sprint_id| available_sprints[sprint_id.to_i] } + end + + private + + def backlogs_enabled? + project.nil? || project.module_enabled?(:backlogs) + end + + def scrum_projects_active? + OpenProject::FeatureDecisions.scrum_projects_active? + end + + def sprints + @sprints ||= begin + scope = Agile::Sprint.visible + project ? scope.for_project(project) : scope + end + end + end +end diff --git a/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb b/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb new file mode 100644 index 00000000000..23c0f2261d5 --- /dev/null +++ b/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::Backlogs::SprintFilter do + let(:scope_class) do + Class.new do + def for_project(_project); end + + def pluck(*_args); end + end + end + let(:sprint) { build_stubbed(:agile_sprint) } + + it_behaves_like "basic query filter" do + let(:type) { :list_optional } + let(:class_key) { :sprint_id } + let(:values) { [sprint.id.to_s] } + let(:model) { WorkPackage } + + let(:visible_scope) { instance_double(scope_class) } + let(:scope) { instance_double(scope_class) } + + before do + allow(project).to receive(:module_enabled?).with(:backlogs).and_return(true) + allow(OpenProject::FeatureDecisions).to receive(:scrum_projects_active?).and_return(true) + allow(Agile::Sprint) + .to receive(:visible) + .and_return(visible_scope) + + if project + allow(visible_scope) + .to receive(:for_project) + .with(project) + .and_return(scope) + end + + allow(scope).to receive(:pluck).with(:id, :id).and_return([[sprint.id, sprint.id]]) + allow(visible_scope).to receive(:pluck).with(:id, :id).and_return([[sprint.id, sprint.id]]) + end + + describe "#available?" do + context "when scrum projects is active and backlogs is enabled" do + it "is true" do + expect(instance).to be_available + end + end + + context "when scrum projects is inactive" do + before do + allow(OpenProject::FeatureDecisions).to receive(:scrum_projects_active?).and_return(false) + end + + it "is false" do + expect(instance).not_to be_available + end + end + + context "when backlogs is not enabled" do + before do + allow(project).to receive(:module_enabled?).with(:backlogs).and_return(false) + end + + it "is false" do + expect(instance).not_to be_available + end + end + end + + describe "#ar_object_filter?" do + it "is true" do + expect(instance).to be_ar_object_filter + end + end + + describe "#value_objects" do + let(:sprint1) { build_stubbed(:agile_sprint) } + let(:sprint2) { build_stubbed(:agile_sprint) } + + before do + allow(visible_scope) + .to receive(:for_project) + .with(project) + .and_return([sprint1, sprint2]) + + instance.values = [sprint1.id.to_s] + end + + it "returns an array of sprints" do + expect(instance.value_objects) + .to contain_exactly(sprint1) + end + end + end +end From 24702a922ebd7b4bed54b522983443e662a9c1f2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 10 Mar 2026 23:52:16 -0300 Subject: [PATCH 139/435] Add sprint board permission dependencies Make backlogs sprint permissions depend on the new boards permissions used by sprint task boards. - view_sprints now depends on show_board_views - start_complete_sprint now depends on manage_board_views - update focused permissions specs accordingly --- modules/backlogs/lib/open_project/backlogs/engine.rb | 4 ++-- .../spec/lib/open_project/backlogs/permissions_spec.rb | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 0d68caeb4e4..5096b6134b3 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -78,7 +78,7 @@ module OpenProject::Backlogs rb_tasks: %i[index show], rb_impediments: %i[index show] }, permissible_on: :project, - dependencies: :view_work_packages + dependencies: %i[view_work_packages show_board_views] permission :select_done_statuses, { @@ -98,7 +98,7 @@ module OpenProject::Backlogs {}, permissible_on: :project, require: :member, - dependencies: :view_sprints, + dependencies: %i[view_sprints manage_board_views], visible: -> { OpenProject::FeatureDecisions.scrum_projects_active? } permission :manage_sprint_items, diff --git a/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb b/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb index 8d32adaaf88..26e31ebc4ca 100644 --- a/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb +++ b/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb @@ -34,8 +34,8 @@ RSpec.describe OpenProject::AccessControl, "Backlogs module permissions" do # ru describe "view_sprints" do subject { described_class.permission(:view_sprints) } - it "depends on view_work_packages" do - expect(subject.dependencies).to contain_exactly(:view_work_packages) + it "depends on view_work_packages and show_board_views" do + expect(subject.dependencies).to contain_exactly(:view_work_packages, :show_board_views) end end @@ -58,8 +58,8 @@ RSpec.describe OpenProject::AccessControl, "Backlogs module permissions" do # ru describe "start_complete_sprint" do subject { described_class.permission(:start_complete_sprint) } - it "depends on view_sprints" do - expect(subject.dependencies).to contain_exactly(:view_sprints) + it "depends on view_sprints and manage_board_views" do + expect(subject.dependencies).to contain_exactly(:view_sprints, :manage_board_views) end context "when scrum_projects feature flag is active", with_flag: { scrum_projects: true } do From 014e419873219db4e5fd41b6bf01a7297dd32669 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 11 Mar 2026 00:31:25 -0300 Subject: [PATCH 140/435] Implement Agile::Sprint#board_name --- modules/backlogs/app/models/agile/sprint.rb | 4 ++++ modules/backlogs/spec/models/agile/sprint_spec.rb | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index cef4d7f6f34..d126a02cabd 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -74,6 +74,10 @@ module Agile Day.working.from_range(from: start_date, to: finish_date).count end + def board_name + "#{project.name}: #{name}" + end + private # TODO: consider moving this validation to the database level to ensure data integrity. diff --git a/modules/backlogs/spec/models/agile/sprint_spec.rb b/modules/backlogs/spec/models/agile/sprint_spec.rb index 5ce79972dea..7be42b08c86 100644 --- a/modules/backlogs/spec/models/agile/sprint_spec.rb +++ b/modules/backlogs/spec/models/agile/sprint_spec.rb @@ -119,6 +119,12 @@ RSpec.describe Agile::Sprint do it { is_expected.to belong_to(:project) } end + describe "#board_name" do + it "returns the project and sprint name" do + expect(sprint.board_name).to eq("#{project.name}: Sprint 1") + end + end + describe "work_package association" do let(:sprint) { create(:agile_sprint, project:) } let(:work_package) { create(:work_package, project:, sprint:) } From cd0587bde8edc342bce7485bf3055b9c6d705c2b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 11 Mar 2026 20:46:29 -0300 Subject: [PATCH 141/435] Add polymorphic board linkage Add optional linked_type/linked_id columns to grids and wire sprint task board lookup through model associations. --- modules/backlogs/app/models/agile/sprint.rb | 9 +++ .../backlogs/spec/models/agile/sprint_spec.rb | 40 ++++++++++ modules/boards/app/models/boards/grid.rb | 3 + .../app/services/boards/copy_service.rb | 4 + .../boards/spec/models/boards/grid_spec.rb | 4 + .../copy_service_sprint_board_spec.rb | 77 +++++++++++++++++++ .../20260311183000_add_linked_to_grids.rb | 10 +++ 7 files changed, 147 insertions(+) create mode 100644 modules/boards/spec/services/projects/copy_service_sprint_board_spec.rb create mode 100644 modules/grids/db/migrate/20260311183000_add_linked_to_grids.rb diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index d126a02cabd..69500c11e4c 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -39,6 +39,11 @@ module Agile belongs_to :project has_many :work_packages, dependent: :nullify + has_one :task_board, + as: :linked, + class_name: "Boards::Grid", + inverse_of: :linked, + dependent: :nullify scopes :for_project, :not_completed, @@ -78,6 +83,10 @@ module Agile "#{project.name}: #{name}" end + def task_board? + task_board.present? + end + private # TODO: consider moving this validation to the database level to ensure data integrity. diff --git a/modules/backlogs/spec/models/agile/sprint_spec.rb b/modules/backlogs/spec/models/agile/sprint_spec.rb index 7be42b08c86..a004b3a5b2c 100644 --- a/modules/backlogs/spec/models/agile/sprint_spec.rb +++ b/modules/backlogs/spec/models/agile/sprint_spec.rb @@ -125,6 +125,46 @@ RSpec.describe Agile::Sprint do end end + describe "#task_board" do + let(:sprint) { create(:agile_sprint, project:) } + + context "when a sprint task board exists" do + let!(:board) do + create(:board_grid_with_query, + project:, + name: "Renamed board", + linked: sprint) + end + + it "returns the existing board" do + expect(sprint.task_board).to eq(board) + end + + it "returns true for #task_board?" do + expect(sprint).to be_task_board + end + end + + context "when only same-name or same-filter boards exist" do + let!(:same_name_board) { create(:board_grid_with_query, project:, name: sprint.board_name) } + let!(:matching_filters_board) do + create(:board_grid_with_query, + project:, + options: { + "filters" => [{ "sprint_id" => { "operator" => "=", "values" => [sprint.id.to_s] } }] + }) + end + + it "returns nil" do + expect(sprint.task_board).to be_nil + end + + it "returns false for #task_board?" do + expect(sprint).not_to be_task_board + end + end + end + describe "work_package association" do let(:sprint) { create(:agile_sprint, project:) } let(:work_package) { create(:work_package, project:, sprint:) } diff --git a/modules/boards/app/models/boards/grid.rb b/modules/boards/app/models/boards/grid.rb index 5201fcdb860..92d14cde65b 100644 --- a/modules/boards/app/models/boards/grid.rb +++ b/modules/boards/app/models/boards/grid.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -29,6 +31,7 @@ module Boards class Grid < ::Grids::Grid belongs_to :project + belongs_to :linked, polymorphic: true, optional: true, inverse_of: :task_board validates :name, presence: true before_destroy :delete_queries, prepend: true diff --git a/modules/boards/app/services/boards/copy_service.rb b/modules/boards/app/services/boards/copy_service.rb index ae180cb02e9..a42531c3230 100644 --- a/modules/boards/app/services/boards/copy_service.rb +++ b/modules/boards/app/services/boards/copy_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -33,6 +35,8 @@ module Boards def set_attributes_params(params) super.deep_symbolize_keys.tap do |hash| hash[:project] = state.project || model.project + hash[:linked_type] = nil + hash[:linked_id] = nil hash[:options] = mapped_options(hash[:options]) if hash.key?(:options) end diff --git a/modules/boards/spec/models/boards/grid_spec.rb b/modules/boards/spec/models/boards/grid_spec.rb index e90a3f9ac0b..3688cb8395b 100644 --- a/modules/boards/spec/models/boards/grid_spec.rb +++ b/modules/boards/spec/models/boards/grid_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -33,6 +35,8 @@ RSpec.describe Boards::Grid do let(:project) { build_stubbed(:project) } describe "attributes" do + it { is_expected.to belong_to(:linked).optional } + it "#project" do instance.project = project expect(instance.project) diff --git a/modules/boards/spec/services/projects/copy_service_sprint_board_spec.rb b/modules/boards/spec/services/projects/copy_service_sprint_board_spec.rb new file mode 100644 index 00000000000..5afe343b255 --- /dev/null +++ b/modules/boards/spec/services/projects/copy_service_sprint_board_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Projects::CopyService, "integration", type: :model do + let(:current_user) do + create(:user, member_with_roles: { source => role }) + end + let(:project_copy) { subject.result } + let(:board_copies) { Boards::Grid.where(project: project_copy) } + let(:board_copy) { board_copies.first } + let!(:source) { create(:project, enabled_module_names: %w[boards work_package_tracking]) } + let(:role) { create(:project_role, permissions: %i[copy_projects]) } + let(:instance) do + described_class.new(source:, user: current_user) + end + let(:only_args) { %w[work_packages boards] } + let(:target_project_params) do + { name: "Some name", identifier: "some-identifier" } + end + let(:params) do + { target_project_params:, only: only_args } + end + + subject { instance.call(params) } + + describe "for an automatically generated sprint board" do + let!(:board_view) do + create( + :board_grid, + project: source, + linked: create(:agile_sprint, project: source), + options: { + "filters" => [{ "sprint_id" => { "operator" => "=", "values" => ["123"] } }] + } + ) + end + + before do + login_as current_user + end + + it "removes the sprint linkage from the copy" do + expect(subject).to be_success + expect(board_copies.count).to eq 1 + expect(board_copy.linked).to be_nil + end + end +end diff --git a/modules/grids/db/migrate/20260311183000_add_linked_to_grids.rb b/modules/grids/db/migrate/20260311183000_add_linked_to_grids.rb new file mode 100644 index 00000000000..0566d530e1e --- /dev/null +++ b/modules/grids/db/migrate/20260311183000_add_linked_to_grids.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddLinkedToGrids < ActiveRecord::Migration[8.0] + def change + add_reference :grids, :linked, polymorphic: true, index: false + add_index :grids, + %i[project_id linked_type linked_id], + name: "index_grids_on_project_and_linked" + end +end From acc92848bda0a4683b14aff6979e1aabfc9d4df0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 9 Mar 2026 15:57:36 -0300 Subject: [PATCH 142/435] [#69139] Add sprint task board creation Introduce Boards::SprintTaskBoardCreateService and use it to create a sprint task board with one query and widget per task status. https://community.openproject.org/wp/69139 --- .../controllers/rb_taskboards_controller.rb | 43 ++++++- modules/backlogs/config/locales/en.yml | 1 + .../rb_taskboards_controller_spec.rb | 109 ++++++++++++++---- .../sprint_task_board_create_service.rb | 102 ++++++++++++++++ .../sprint_task_board_create_service_spec.rb | 108 +++++++++++++++++ 5 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 modules/boards/app/services/boards/sprint_task_board_create_service.rb create mode 100644 modules/boards/spec/services/boards/sprint_task_board_create_service_spec.rb diff --git a/modules/backlogs/app/controllers/rb_taskboards_controller.rb b/modules/backlogs/app/controllers/rb_taskboards_controller.rb index 05196ec200e..90fc74042e8 100644 --- a/modules/backlogs/app/controllers/rb_taskboards_controller.rb +++ b/modules/backlogs/app/controllers/rb_taskboards_controller.rb @@ -33,11 +33,44 @@ class RbTaskboardsController < RbApplicationController helper :taskboards + before_action :load_or_create_board, if: -> { OpenProject::FeatureDecisions.scrum_projects_active? } + def show - @statuses = Type.find(Task.type).statuses - @story_ids = @sprint.stories(@project).map(&:id) - @last_updated = Task.children_of(@story_ids) - .order(Arel.sql("updated_at DESC")) - .first + if OpenProject::FeatureDecisions.scrum_projects_active? + redirect_to project_work_package_board_path(@project, @board) + else + @statuses = Type.find(Task.type).statuses + @story_ids = @sprint.stories(@project).map(&:id) + @last_updated = Task.children_of(@story_ids) + .order(Arel.sql("updated_at DESC")) + .first + end + end + + private + + def load_or_create_board + result = Boards::SprintTaskBoardCreateService + .new(user: current_user) + .call(project: @project, sprint: @sprint, name: @sprint.board_name) + + if result.success? + @board = result.result + else + flash[:error] = t(:error_task_board_creation_failed) + return redirect_back_or_to(backlogs_project_backlogs_path(@project)) # rubocop:disable Style/RedundantReturn + end + end + + def load_sprint_and_project + @project = Project.visible.find(params[:project_id]) + + return unless (@sprint_id = params.delete(:sprint_id)) + + @sprint = if OpenProject::FeatureDecisions.scrum_projects_active? + Agile::Sprint.for_project(@project).visible.find(@sprint_id) + else + Sprint.visible.apply_to(@project).find(@sprint_id) + end end end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index ef130788c11..bfc0ebbc39d 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -187,6 +187,7 @@ en: label_sprint_impediments: "Sprint Impediments" label_sprint_new: "New sprint" label_task_board: "Task board" + error_task_board_creation_failed: "The task board could not be created automatically." permission_create_sprints: "Create sprints" permission_manage_sprint_items: "Manage sprint items" diff --git a/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb b/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb index b474ea826a8..5d74fd53b69 100644 --- a/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb @@ -49,39 +49,104 @@ RSpec.describe RbTaskboardsController do end describe "GET show" do - before do - get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + context "with the feature flag active", with_flag: { scrum_projects: true } do + let(:sprint) { create(:agile_sprint, project:) } + let(:board) { build_stubbed(:board_grid) } + let(:service_result) { ServiceResult.success(result: board) } + let(:service) { instance_double(Boards::SprintTaskBoardCreateService, call: service_result) } + + before do + allow(Boards::SprintTaskBoardCreateService) + .to receive(:new) + .with(user:) + .and_return(service) + + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end + + it "redirects to the board" do + expect(response).to redirect_to(project_work_package_board_path(project, board)) + end + + context "as a member with view_sprints permission" do + let(:user) { create(:user) } + let(:permissions) { %i[view_sprints view_work_packages show_board_views] } + + it "grants access" do + expect(response).to redirect_to(project_work_package_board_path(project, board)) + end + end + + context "as a member without view_sprints permission" do + let(:user) { create(:user) } + let(:permissions) { [:view_project] } + + it "denies access" do + expect(response).to have_http_status(:forbidden) + end + end + + context "as a non-member" do + current_user { create(:user) } + + it "denies access" do + expect(response).to have_http_status(:not_found) + end + end + + context "when board creation fails" do + let(:permissions) { %i[view_sprints view_work_packages show_board_views] } + let(:service_result) { ServiceResult.failure(message: "something went wrong") } + + before do + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end + + it "redirects to the backlogs page" do + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + end + + it "sets a flash error" do + expect(flash[:error]).to be_present + end + end end - it "performs that request" do - expect(response).to be_successful - expect(response).to render_template :show - end + context "with the feature flag inactive", with_flag: { scrum_projects: false } do + before do + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end - context "as a member with view_sprints permission" do - let(:user) { create(:user) } - let(:permissions) { %i[view_sprints view_work_packages] } - - it "grants access" do + it "renders the legacy show template" do expect(response).to be_successful expect(response).to render_template :show end - end - context "as a member without view_sprints permission" do - let(:user) { create(:user) } - let(:permissions) { [:view_project] } + context "as a member with view_sprints permission" do + let(:user) { create(:user) } + let(:permissions) { %i[view_sprints view_work_packages] } - it "denies access" do - expect(response).to have_http_status(:not_found) + it "grants access" do + expect(response).to be_successful + expect(response).to render_template :show + end end - end - context "as a non-member" do - current_user { create(:user) } + context "as a member without view_sprints permission" do + let(:user) { create(:user) } + let(:permissions) { [:view_project] } - it "denies access" do - expect(response).to have_http_status(:not_found) + it "denies access" do + expect(response).to have_http_status(:not_found) + end + end + + context "as a non-member" do + current_user { create(:user) } + + it "denies access" do + expect(response).to have_http_status(:not_found) + end end end end diff --git a/modules/boards/app/services/boards/sprint_task_board_create_service.rb b/modules/boards/app/services/boards/sprint_task_board_create_service.rb new file mode 100644 index 00000000000..7845ba94347 --- /dev/null +++ b/modules/boards/app/services/boards/sprint_task_board_create_service.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Boards + class SprintTaskBoardCreateService < BaseCreateService + protected + + def before_perform(_service_result) + create_queries_results = create_queries(params) + + return create_queries_results.find(&:failure?) if create_queries_results.any?(&:failure?) + + set_attributes(params.merge(query_ids: create_queries_results.map { it.result.id })).tap do |service_result| + service_result.result.linked = params[:sprint] if service_result.success? + end + end + + private + + def create_query_params(params, status) + default_create_query_params(params).merge( + name: query_name(status), + filters: query_filters(status) + ) + end + + def column_count_for_board + statuses.count + end + + def create_queries(params) + statuses.map do |status| + Queries::CreateService.new(user:) + .call(create_query_params(params, status)) + end + end + + def statuses + @statuses ||= Type.find(Task.type).statuses + end + + def query_name(status) + status.name + end + + def query_filters(status) + [{ status_id: { operator: "=", values: [status.id.to_s] } }] + end + + def options_for_grid(_params) + { + type: "action", + attribute: "status", + highlightingMode: "priority", + filters: [{ sprint_id: { operator: "=", values: [params[:sprint].id.to_s] } }] + } + end + + def options_for_widgets(params) + params[:query_ids].zip(statuses).map.with_index do |(query_id, status), index| + Grids::Widget.new( + start_row: 1, + start_column: 1 + index, + end_row: 2, + end_column: 2 + index, + identifier: "work_package_query", + options: { + "queryId" => query_id, + "filters" => query_filters(status) + } + ) + end + end + end +end diff --git a/modules/boards/spec/services/boards/sprint_task_board_create_service_spec.rb b/modules/boards/spec/services/boards/sprint_task_board_create_service_spec.rb new file mode 100644 index 00000000000..a344d041930 --- /dev/null +++ b/modules/boards/spec/services/boards/sprint_task_board_create_service_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../base_create_service_shared_examples" + +RSpec.describe Boards::SprintTaskBoardCreateService do + shared_let(:project) { create(:project) } + shared_let(:sprint) { create(:agile_sprint, project:) } + shared_let(:type_task) { create(:type_task) } + shared_let(:status1) { create(:status) } + shared_let(:status2) { create(:status) } + let(:user) { create(:admin) } + let(:instance) { described_class.new(user:) } + + before do + create(:workflow, type: type_task, old_status: status1, new_status: status2, role: create(:project_role)) + + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return({ "task_type" => type_task.id.to_s }) + end + + subject { instance.call(project:, sprint:, name: "Sprint Task Board") } + + context "with all valid params" do + it "is successful" do + expect(subject).to be_success + end + + it 'creates a "Status" action board', :aggregate_failures do + board = subject.result + + expect(board.name).to eq("Sprint Task Board") + expect(board.linked).to eq(sprint) + expect(board.options[:type]).to eq("action") + expect(board.options[:attribute]).to eq("status") + expect(board.options[:highlightingMode]).to eq("priority") + expect(board.options[:filters]).to eq( + [{ sprint_id: { operator: "=", values: [sprint.id.to_s] } }] + ) + end + + describe "column_count" do + it "matches the number of task type statuses" do + expect(subject.result.column_count).to eq(2) + end + end + + describe "widgets and queries" do + let(:board) { subject.result } + let(:widgets) { board.widgets } + let(:queries) { Query.all } + + it "creates one of each per task type status", :aggregate_failures do + subject + + expect(widgets.count).to eq(2) + expect(queries.count).to eq(2) + expect(queries.map(&:name)).to contain_exactly(status1.name, status2.name) + end + + it "sets the status_id filter on each query and widget", :aggregate_failures do + subject + + queries_filters = queries.flat_map(&:filters).map(&:to_hash) + widgets_filters = widgets.flat_map { it.options["filters"] } + + expect(queries_filters).to match_array(widgets_filters) + + queries.each do |query| + status_filter = query.filters.find { |f| f.field.to_s == "status_id" } + expect(status_filter).not_to be_nil + expect(status_filter.operator).to eq("=") + end + end + + it_behaves_like "sets the appropriate sort_criteria on each query" + end + end +end From fe08927d8aa462e95252e1bc5f4d1f563253e400 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 08:40:16 -0500 Subject: [PATCH 143/435] Align sprint and backlog menu helpers Use within_menu_controlled_by for sprint menus as well, and resolve the controlled menu from page scope before entering it. This keeps within_sprint_menu aligned with within_backlog_menu. Follow up to 39ed4853b05 and 7f5adb83182. --- modules/backlogs/spec/support/pages/backlogs.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlogs.rb b/modules/backlogs/spec/support/pages/backlogs.rb index 2006e49fb4b..0474599feff 100644 --- a/modules/backlogs/spec/support/pages/backlogs.rb +++ b/modules/backlogs/spec/support/pages/backlogs.rb @@ -251,9 +251,10 @@ module Pages def within_sprint_menu(backlog, &) within_sprint(backlog) do - find(:button, accessible_name: "Sprint actions").click + button = find(:button, accessible_name: "Sprint actions") + button.click - within(:menu, &) + within_menu_controlled_by(button, &) end end @@ -289,8 +290,9 @@ module Pages def within_menu_controlled_by(button) menu_id = button[:controls] || button["aria-controls"] + menu = page.find(:menu, id: menu_id) - within(:menu, id: menu_id) do + within(menu) do yield page end end From c22c90baa887c41f861dec6d30bea3abd52de3e2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 12 Mar 2026 21:29:39 -0300 Subject: [PATCH 144/435] [#72942] Add Start sprint button Add the Start sprint action to the sprint menu and route it through RbSprintsController so starting a sprint creates or reuses the linked sprint board and redirects to it. This also moves the start logic into Sprints::StartService so board creation and sprint activation happen within one transaction. https://community.openproject.org/wp/72942 --- .../backlogs/sprint_menu_component.html.erb | 33 +++-- .../backlogs/sprint_menu_component.rb | 12 ++ .../app/controllers/rb_sprints_controller.rb | 48 ++++++- .../controllers/rb_taskboards_controller.rb | 21 +-- .../app/services/sprints/start_service.rb | 80 +++++++++++ modules/backlogs/config/locales/en.yml | 3 + modules/backlogs/config/routes.rb | 3 + .../lib/open_project/backlogs/engine.rb | 2 +- .../backlogs/sprint_menu_component_spec.rb | 84 +++++++++++- .../controllers/rb_sprints_controller_spec.rb | 127 +++++++++++++++++- .../rb_taskboards_controller_spec.rb | 81 ++++++----- .../spec/features/sprints/edit_spec.rb | 28 +++- .../spec/routing/rb_sprints_routing_spec.rb | 11 ++ .../services/sprints/start_service_spec.rb | 123 +++++++++++++++++ 14 files changed, 580 insertions(+), 76 deletions(-) create mode 100644 modules/backlogs/app/services/sprints/start_service.rb create mode 100644 modules/backlogs/spec/services/sprints/start_service_spec.rb diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb index 13d99c433c6..4cb3d7433cb 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb @@ -36,6 +36,20 @@ See COPYRIGHT and LICENSE files for more details. tooltip_direction: :se ) + if show_start_sprint_action? + menu.with_item( + label: t(".action_menu.start_sprint"), + tag: :button, + href: start_project_sprint_path(project, sprint), + form_arguments: { + method: :patch, + data: { turbo: false } + } + ) do |item| + item.with_leading_visual_icon(icon: :play) + end + end + if user_allowed?(:create_sprints) menu.with_item( id: dom_target(sprint, :menu, :edit_sprint), @@ -77,15 +91,16 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :"op-view-list") end - # menu.with_item( - # # TODO: what to do with the task board? - # label: t(".action_menu.task_board"), - # tag: :a, - # href: backlogs_project_sprint_taskboard_path(project, sprint) - # ) do |item| - # item.with_leading_visual_icon(icon: :"op-view-cards") - # end - # + if show_task_board_link? + menu.with_item( + label: t(".action_menu.task_board"), + tag: :a, + href: backlogs_project_sprint_taskboard_path(project, sprint) + ) do |item| + item.with_leading_visual_icon(icon: :"op-view-cards") + end + end + # menu.with_item( # # TODO: what to do with the burndown chart? # label: t(".action_menu.burndown_chart"), diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb index 91e3dc15436..39d96102b7e 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb @@ -56,6 +56,18 @@ module Backlogs private + def scrum_projects_active? + OpenProject::FeatureDecisions.scrum_projects_active? + end + + def show_task_board_link? + !scrum_projects_active? || !sprint.in_planning? + end + + def show_start_sprint_action? + scrum_projects_active? && sprint.in_planning? && user_allowed?(:start_complete_sprint) + end + def user_allowed?(permission) current_user.allowed_in_project?(permission, project) end diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index 2978ea374b9..a0373f3a185 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -36,12 +36,13 @@ class RbSprintsController < RbApplicationController create refresh_form update_agile_sprint].freeze + START_ACTIONS = %i[start].freeze skip_before_action :load_sprint_and_project, only: NEW_SPRINT_ACTIONS before_action :not_authorized_on_feature_flag_inactive, :load_project, - only: NEW_SPRINT_ACTIONS + only: NEW_SPRINT_ACTIONS + START_ACTIONS def new_dialog call = Sprints::SetAttributesService.new( @@ -106,6 +107,20 @@ class RbSprintsController < RbApplicationController respond_with_turbo_streams end + def start + return render_404 unless @sprint.in_planning? + + result = start_sprint + + if result.success? + @sprint = result.result + redirect_to project_work_package_board_path(@project, @sprint.task_board), + notice: I18n.t(:notice_successful_update) + else + respond_with_start_failure(message: start_failure_message(result.message)) + end + end + def edit_name update_header_component_via_turbo_stream(state: :edit) respond_with_turbo_streams @@ -172,8 +187,14 @@ class RbSprintsController < RbApplicationController # Overrides load_sprint_and_project to load the sprint from :id instead of :sprint_id def load_sprint_and_project - @sprint = Sprint.visible.find(params[:id]) load_project + + @sprint = if OpenProject::FeatureDecisions.scrum_projects_active? && + (NEW_SPRINT_ACTIONS + START_ACTIONS).include?(action_name.to_sym) + Agile::Sprint.for_project(@project).visible.find(params[:id]) + else + Sprint.visible.find(params[:id]) + end end def sprint_params @@ -196,6 +217,29 @@ class RbSprintsController < RbApplicationController converted_sprint_params end + def start_sprint + Sprints::StartService + .new(user: current_user, model: @sprint) + .call + end + + def respond_with_start_failure(message:) + render_error_flash_message_via_turbo_stream(message:) + + respond_with_turbo_streams(status: :unprocessable_entity) do |format| + format.html do + redirect_back_or_to(backlogs_project_backlogs_path(@project), alert: message) + end + end + end + + def start_failure_message(reason) + if reason.present? + I18n.t(:notice_unsuccessful_start_with_reason, reason:) + else + I18n.t(:notice_unsuccessful_start) + end + end def not_authorized_on_feature_flag_inactive render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? end diff --git a/modules/backlogs/app/controllers/rb_taskboards_controller.rb b/modules/backlogs/app/controllers/rb_taskboards_controller.rb index 90fc74042e8..d6d18c692ee 100644 --- a/modules/backlogs/app/controllers/rb_taskboards_controller.rb +++ b/modules/backlogs/app/controllers/rb_taskboards_controller.rb @@ -33,11 +33,13 @@ class RbTaskboardsController < RbApplicationController helper :taskboards - before_action :load_or_create_board, if: -> { OpenProject::FeatureDecisions.scrum_projects_active? } - def show if OpenProject::FeatureDecisions.scrum_projects_active? - redirect_to project_work_package_board_path(@project, @board) + @board = @sprint.task_board + + return redirect_to(project_work_package_board_path(@project, @board)) if @board + + render_404 else @statuses = Type.find(Task.type).statuses @story_ids = @sprint.stories(@project).map(&:id) @@ -49,19 +51,6 @@ class RbTaskboardsController < RbApplicationController private - def load_or_create_board - result = Boards::SprintTaskBoardCreateService - .new(user: current_user) - .call(project: @project, sprint: @sprint, name: @sprint.board_name) - - if result.success? - @board = result.result - else - flash[:error] = t(:error_task_board_creation_failed) - return redirect_back_or_to(backlogs_project_backlogs_path(@project)) # rubocop:disable Style/RedundantReturn - end - end - def load_sprint_and_project @project = Project.visible.find(params[:project_id]) diff --git a/modules/backlogs/app/services/sprints/start_service.rb b/modules/backlogs/app/services/sprints/start_service.rb new file mode 100644 index 00000000000..a8f75df152e --- /dev/null +++ b/modules/backlogs/app/services/sprints/start_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Sprints::StartService < BaseServices::BaseCallable + include Shared::ServiceContext + + attr_reader :user, :model + + def initialize(user:, model:) + super() + @user = user + @model = model + end + + def perform + in_context(model, send_notifications: false) do + start_sprint + end + end + + private + + def start_sprint + return unsuccessful_start_result unless model.in_planning? + + result = ensure_task_board + return result if result.failure? + + model.active! + + ServiceResult.success(result: model) + rescue ActiveRecord::RecordInvalid + unsuccessful_start_result + end + + def unsuccessful_start_result + ServiceResult.failure(result: model, + errors: model.errors, + message: unsuccessful_start_message) + end + + def ensure_task_board + return ServiceResult.success(result: model.task_board) if model.task_board? + + Boards::SprintTaskBoardCreateService + .new(user:) + .call(project: model.project, sprint: model, name: model.board_name) + end + + def unsuccessful_start_message + model.errors.full_messages.to_sentence if model.errors.any? + end +end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index bfc0ebbc39d..d44babee6ea 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -141,6 +141,7 @@ en: sprint_menu_component: label_actions: "Sprint actions" action_menu: + start_sprint: "Start sprint" edit_sprint: "Edit sprint" new_story: "New story" stories_tasks: "Stories/Tasks" @@ -188,6 +189,8 @@ en: label_sprint_new: "New sprint" label_task_board: "Task board" error_task_board_creation_failed: "The task board could not be created automatically." + notice_unsuccessful_start: "The sprint could not be started." + notice_unsuccessful_start_with_reason: "The sprint could not be started: %{reason}" permission_create_sprints: "Create sprints" permission_manage_sprint_items: "Manage sprint items" diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index f4a3e0436d5..d1682157b5f 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -37,6 +39,7 @@ Rails.application.routes.draw do end member do + patch :start get :edit_dialog put :update_agile_sprint end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 5096b6134b3..710747c39ef 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -95,7 +95,7 @@ module OpenProject::Backlogs dependencies: :view_sprints permission :start_complete_sprint, - {}, + { rb_sprints: :start }, permissible_on: :project, require: :member, dependencies: %i[view_sprints manage_board_views], diff --git a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb index c2c96a07263..217320ada90 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb @@ -36,16 +36,15 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do let(:project) { create(:project, types: [type_feature, type_task]) } let(:sprint) { create(:agile_sprint, project:, name: "Sprint 1", start_date: Date.yesterday, finish_date: Date.tomorrow) } - let(:stories) { [] } let(:user) { create(:user) } let(:permissions) { [] } + let(:start_sprint_path) { Rails.application.routes.url_helpers.start_project_sprint_path(project, sprint) } before do allow(Setting) .to receive(:plugin_openproject_backlogs) .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) - # Set up user with specific permissions create(:member, project:, principal: user, @@ -57,6 +56,10 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do render_inline(described_class.new(sprint:, project:, current_user: user)) end + def menu_items + page.all(:role, :menuitem).map { it.text.squish } + end + describe "permission-based items" do context "with :manage_sprint_items permission" do let(:permissions) { %i[view_sprints manage_sprint_items] } @@ -102,6 +105,83 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do end end + describe "task board actions" do + let(:permissions) { %i[view_sprints view_work_packages] } + + context "with the feature flag inactive", with_flag: { scrum_projects: false } do + it "shows Task board after Stories/Tasks" do + render_component + + expect(menu_items).to include("Stories/Tasks", "Task board") + expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") + end + end + + context "with the feature flag active", with_flag: { scrum_projects: true } do + context "when the sprint is active" do + let(:sprint) do + create(:agile_sprint, + project:, + name: "Sprint 1", + start_date: Date.yesterday, + finish_date: Date.tomorrow, + status: "active") + end + + it "shows Task board after Stories/Tasks" do + render_component + + expect(menu_items).to include("Stories/Tasks", "Task board") + expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") + end + end + + context "when the sprint is in planning and the user can start it" do + let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } + + it "shows Start sprint as the first item" do + render_component + + expect(menu_items.first).to eq("Start sprint") + expect(page).to have_octicon(:play) + expect(page).to have_no_selector(:menuitem, text: "Task board") + expect(page).to have_css( + "form[action='#{start_sprint_path}'][data-turbo='false'] " \ + "input[name='_method'][value='patch']", + visible: :hidden + ) + end + end + + context "when the sprint is in planning and the user cannot start it" do + it "does not show task-board-related items" do + render_component + + expect(page).to have_no_selector(:menuitem, text: "Start sprint") + expect(page).to have_no_selector(:menuitem, text: "Task board") + end + end + + context "when the sprint is completed" do + let(:sprint) do + create(:agile_sprint, + project:, + name: "Sprint 1", + start_date: Date.yesterday, + finish_date: Date.tomorrow, + status: "completed") + end + + it "shows Task board after Stories/Tasks" do + render_component + + expect(menu_items).to include("Stories/Tasks", "Task board") + expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") + end + end + end + end + describe "always-visible items" do let(:permissions) { [:view_sprints] } diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index 396fb2b9e1b..97fff7849ea 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -157,7 +157,7 @@ RSpec.describe RbSprintsController do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } - let(:all_permissions) { %i[view_sprints view_work_packages create_sprints] } + let(:all_permissions) { %i[view_sprints view_work_packages create_sprints start_complete_sprint] } let(:permissions) { all_permissions } let(:user) do create(:user, member_with_permissions: { project => permissions }) @@ -331,6 +331,131 @@ RSpec.describe RbSprintsController do end end + describe "PATCH #start" do + let!(:sprint) { create(:agile_sprint, project:) } + let(:service_result) { ServiceResult.success(result: sprint.tap { it.status = "active" }) } + let(:service) { instance_double(Sprints::StartService, call: service_result) } + let(:request_params) { { project_id: project.id, id: sprint.id } } + + before do + allow(Sprints::StartService) + .to receive(:new) + .with(user:, model: sprint) + .and_return(service) + end + + context "with the feature flag inactive" do + it "responds with not found" do + patch :start, params: request_params, format: :turbo_stream + + expect(response).not_to be_successful + expect(response).to have_http_status(:not_found) + end + end + + context "with the feature flag active", with_flag: { scrum_projects: true } do + context "when a board already exists" do + let!(:existing_board) do + create(:board_grid_with_query, + project:, + linked: sprint) + end + + it "starts the sprint and redirects to the board", :aggregate_failures do + patch :start, params: request_params + + expect(response).to redirect_to(project_work_package_board_path(project, existing_board)) + expect(service).to have_received(:call) + end + end + + context "when board creation succeeds" do + let(:board) { create(:board_grid_with_query, project:, linked: sprint) } + let(:service_result) do + ServiceResult.success( + result: sprint.tap do |started_sprint| + started_sprint.status = "active" + started_sprint.task_board = board + end + ) + end + + it "creates the board, starts the sprint, and redirects to the board", :aggregate_failures do + patch :start, params: request_params + + expect(response).to redirect_to(project_work_package_board_path(project, board)) + expect(service).to have_received(:call) + end + end + + context "when board creation fails" do + let(:service_result) { ServiceResult.failure(message: "something went wrong") } + + it "redirects back to the backlog and leaves the sprint in planning", :aggregate_failures do + patch :start, params: request_params + + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + expect(flash[:alert]).to eq( + I18n.t(:notice_unsuccessful_start_with_reason, reason: "something went wrong") + ) + expect(sprint.reload).to be_in_planning + end + end + + context "when sprint start fails without an explicit message" do + let(:service_result) { ServiceResult.failure } + + it "redirects back with the default start failure message", :aggregate_failures do + patch :start, params: request_params + + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + expect(flash[:alert]).to eq(I18n.t(:notice_unsuccessful_start)) + expect(service).to have_received(:call) + end + end + + context "when another sprint is already active" do + let!(:active_sprint) { create(:agile_sprint, project:, status: "active") } + let(:service_result) do + ServiceResult.failure( + result: sprint, + message: sprint.errors.full_messages.to_sentence + ) + end + + it "redirects back to the backlog and leaves the sprint in planning", :aggregate_failures do + patch :start, params: request_params + + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + expect(flash[:alert]).to eq(I18n.t(:notice_unsuccessful_start)) + expect(service).to have_received(:call) + end + end + + context "without the 'start_complete_sprint' permission" do + let(:permissions) { all_permissions - [:start_complete_sprint] } + + it "responds with forbidden" do + patch :start, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:forbidden) + end + end + + context "when the sprint is already active" do + let!(:sprint) { create(:agile_sprint, project:, status: "active") } + + it "responds with not found" do + patch :start, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:not_found) + end + end + end + end + describe "GET #refresh_form" do context "with the feature flag inactive" do it "responds with forbidden" do diff --git a/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb b/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb index 5d74fd53b69..7823d183760 100644 --- a/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb @@ -33,14 +33,13 @@ require "spec_helper" RSpec.describe RbTaskboardsController do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } - shared_let(:user) { create(:admin) } - current_user { user } - + let(:user) { create(:user) } let(:permissions) { [] } let(:project) { create(:project, member_with_permissions: { user => permissions }) } - let(:status) { create(:status, name: "status 1", is_default: true) } - let(:sprint) { create(:sprint, project:) } - let(:story) { create(:story, status:, version: sprint, project:) } + let(:status) { create(:status, name: "status 1", is_default: true) } + let(:board) { create(:board_grid_with_query, project:) } + + current_user { user } before do allow(Setting) @@ -51,36 +50,45 @@ RSpec.describe RbTaskboardsController do describe "GET show" do context "with the feature flag active", with_flag: { scrum_projects: true } do let(:sprint) { create(:agile_sprint, project:) } - let(:board) { build_stubbed(:board_grid) } - let(:service_result) { ServiceResult.success(result: board) } - let(:service) { instance_double(Boards::SprintTaskBoardCreateService, call: service_result) } - before do - allow(Boards::SprintTaskBoardCreateService) - .to receive(:new) - .with(user:) - .and_return(service) + context "when the board exists" do + before do + board.update!(linked: sprint) + end - get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + context "as a member with view_sprints permission" do + let(:permissions) { %i[view_sprints view_work_packages] } + + before do + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end + + it "redirects to the board" do + expect(response).to redirect_to(project_work_package_board_path(project, board)) + end + end end - it "redirects to the board" do - expect(response).to redirect_to(project_work_package_board_path(project, board)) - end + context "when the board does not exist" do + let(:permissions) { %i[view_sprints view_work_packages] } - context "as a member with view_sprints permission" do - let(:user) { create(:user) } - let(:permissions) { %i[view_sprints view_work_packages show_board_views] } + before do + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end - it "grants access" do - expect(response).to redirect_to(project_work_package_board_path(project, board)) + it "returns not found" do + expect(response).to have_http_status(:not_found) end end context "as a member without view_sprints permission" do - let(:user) { create(:user) } let(:permissions) { [:view_project] } + before do + board.update!(linked: sprint) + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end + it "denies access" do expect(response).to have_http_status(:forbidden) end @@ -89,30 +97,21 @@ RSpec.describe RbTaskboardsController do context "as a non-member" do current_user { create(:user) } - it "denies access" do - expect(response).to have_http_status(:not_found) - end - end - - context "when board creation fails" do - let(:permissions) { %i[view_sprints view_work_packages show_board_views] } - let(:service_result) { ServiceResult.failure(message: "something went wrong") } - before do + board.update!(linked: sprint) get :show, params: { project_id: project.identifier, sprint_id: sprint.id } end - it "redirects to the backlogs page" do - expect(response).to redirect_to(backlogs_project_backlogs_path(project)) - end - - it "sets a flash error" do - expect(flash[:error]).to be_present + it "denies access" do + expect(response).to have_http_status(:not_found) end end end context "with the feature flag inactive", with_flag: { scrum_projects: false } do + let(:sprint) { create(:sprint, project:) } + let(:permissions) { %i[view_sprints view_work_packages] } + before do get :show, params: { project_id: project.identifier, sprint_id: sprint.id } end @@ -123,7 +122,6 @@ RSpec.describe RbTaskboardsController do end context "as a member with view_sprints permission" do - let(:user) { create(:user) } let(:permissions) { %i[view_sprints view_work_packages] } it "grants access" do @@ -133,7 +131,6 @@ RSpec.describe RbTaskboardsController do end context "as a member without view_sprints permission" do - let(:user) { create(:user) } let(:permissions) { [:view_project] } it "denies access" do @@ -142,6 +139,8 @@ RSpec.describe RbTaskboardsController do end context "as a non-member" do + let(:permissions) { [] } + current_user { create(:user) } it "denies access" do diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb index a41ba4f2ff3..460b24e7d9f 100644 --- a/modules/backlogs/spec/features/sprints/edit_spec.rb +++ b/modules/backlogs/spec/features/sprints/edit_spec.rb @@ -33,7 +33,11 @@ require_relative "../../support/pages/backlogs" RSpec.describe "Edit", :js do let(:project) { create(:project) } - let(:all_permissions) { %i[view_sprints add_work_packages view_work_packages create_sprints manage_sprint_items] } + let(:all_permissions) do + %i[view_sprints add_work_packages view_work_packages create_sprints manage_sprint_items + start_complete_sprint show_board_views manage_board_views save_queries + manage_public_queries] + end let(:permissions) { all_permissions } let(:user) do create(:user, member_with_permissions: { project => permissions }) @@ -102,6 +106,8 @@ RSpec.describe "Edit", :js do inactive_story_type.id.to_s], "task_type" => task_type.id.to_s) + create(:workflow, type: task_type, old_status: default_status, new_status: default_status, role: create(:project_role)) + backlogs_page.visit! end @@ -135,13 +141,25 @@ RSpec.describe "Edit", :js do context "when editing a sprint" do it "displays all menu entries" do backlogs_page.within_sprint_menu(first_sprint) do |menu| - expect(menu).to have_selector :menuitem, count: 3 + expect(menu).to have_selector :menuitem, count: 4 + expect(menu).to have_selector :menuitem, "Start sprint" expect(menu).to have_selector :menuitem, "Edit sprint" expect(menu).to have_selector :menuitem, "New story" expect(menu).to have_selector :menuitem, "Stories/Tasks" + expect(menu).to have_css "form[action='#{start_project_sprint_path(project, first_sprint)}'][data-turbo='false']" end end + it "starts the sprint and redirects to the board" do + backlogs_page.click_in_sprint_menu(first_sprint, "Start sprint") + + expect_and_dismiss_flash type: :success, message: "Successful update." + + expect(page).to have_current_path(%r{/projects/#{project.identifier}/boards/\d+}) + expect(first_sprint.reload.task_board).to be_present + expect(first_sprint.reload).to be_active + end + it "edits the sprint name" do backlogs_page.expect_sprint_names_in_order(first_sprint.name, second_sprint.name) @@ -162,7 +180,8 @@ RSpec.describe "Edit", :js do it "has no menu entry for creating a new story" do backlogs_page.within_sprint_menu(first_sprint) do |menu| - expect(menu).to have_selector :menuitem, count: 2 + expect(menu).to have_selector :menuitem, count: 3 + expect(menu).to have_selector :menuitem, "Start sprint" expect(menu).to have_selector :menuitem, "Edit sprint" expect(menu).to have_selector :menuitem, "Stories/Tasks" @@ -174,7 +193,7 @@ RSpec.describe "Edit", :js do end context "without the necessary permissions" do - let(:permissions) { all_permissions - [:create_sprints] } + let(:permissions) { all_permissions - %i[create_sprints start_complete_sprint] } it "is missing the 'new sprint' button" do expect(page).to have_no_button "Create" @@ -185,6 +204,7 @@ RSpec.describe "Edit", :js do backlogs_page.within_sprint_menu(first_sprint) do |menu| expect(menu).to have_selector :menuitem, "Stories/Tasks" expect(menu).to have_no_selector :menuitem, "Edit sprint" + expect(menu).to have_no_selector :menuitem, "Start sprint" end end end diff --git a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb index 1390a902423..c07bf7c2c48 100644 --- a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -54,5 +56,14 @@ RSpec.describe RbSprintsController do project_id: "project_42", id: "21") } + + it { + expect(patch("/projects/project_42/sprints/21/start")).to route_to( + controller: "rb_sprints", + action: "start", + project_id: "project_42", + id: "21" + ) + } end end diff --git a/modules/backlogs/spec/services/sprints/start_service_spec.rb b/modules/backlogs/spec/services/sprints/start_service_spec.rb new file mode 100644 index 00000000000..deb4fccb588 --- /dev/null +++ b/modules/backlogs/spec/services/sprints/start_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Sprints::StartService do + shared_let(:project) { create(:project) } + shared_let(:type_task) { create(:type_task) } + shared_let(:status1) { create(:status) } + shared_let(:status2) { create(:status) } + let(:status) { "in_planning" } + let(:sprint) { create(:agile_sprint, project:, status:) } + let(:user) { create(:admin) } + let(:instance) { described_class.new(user:, model: sprint) } + + subject(:result) { instance.call } + + before do + create(:workflow, type: type_task, old_status: status1, new_status: status2, role: create(:project_role)) + + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return({ "task_type" => type_task.id.to_s }) + end + + context "when no task board exists yet" do + it "creates the board and starts the sprint", :aggregate_failures do + expect(result).to be_success + expect(sprint.reload).to be_active + expect(sprint.task_board).to be_present + end + end + + context "when a task board already exists" do + let!(:existing_board) { create(:board_grid_with_query, project:, linked: sprint) } + + it "starts the sprint without creating another board", :aggregate_failures do + expect { result }.not_to change(Boards::Grid, :count) + expect(result).to be_success + expect(sprint.reload).to be_active + expect(sprint.task_board).to eq(existing_board) + end + end + + context "when board creation fails" do + let(:service_result) { ServiceResult.failure(message: "something went wrong") } + let(:service) { instance_double(Boards::SprintTaskBoardCreateService, call: service_result) } + + before do + allow(Boards::SprintTaskBoardCreateService) + .to receive(:new) + .with(user:) + .and_return(service) + end + + it "returns failure and leaves the sprint in planning", :aggregate_failures do + expect(result).not_to be_success + expect(result.message).to eq("something went wrong") + expect(sprint.reload).to be_in_planning + expect(sprint.task_board).to be_nil + end + end + + context "when sprint activation fails after board creation" do + let!(:active_sprint) { create(:agile_sprint, project:, status: "active") } + + it "rolls back the created board", :aggregate_failures do + expect(result).not_to be_success + expect(sprint.reload).to be_in_planning + expect(sprint.task_board).to be_nil + expect(result.message).to eq(sprint.errors.full_messages.to_sentence) + end + end + + context "when the sprint is already active" do + let(:status) { "active" } + + it "returns failure and leaves the sprint unchanged", :aggregate_failures do + expect(result).not_to be_success + expect(result.message).to be_blank + expect(sprint.reload).to be_active + expect(sprint.task_board).to be_nil + end + end + + context "when the sprint is already completed" do + let(:status) { "completed" } + + it "returns failure and leaves the sprint unchanged", :aggregate_failures do + expect(result).not_to be_success + expect(result.message).to be_blank + expect(sprint.reload).to be_completed + expect(sprint.task_board).to be_nil + end + end +end From 18c8beff4faad52152bb322c17ea6a0aed979c99 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 12 Mar 2026 22:07:41 -0300 Subject: [PATCH 145/435] Disable Start sprint for active sprint overlap Keep Start sprint visible for users with permission, but disable it when another sprint in the same project is already active. Render an inline description in the action menu to explain why the item is unavailable. --- .../backlogs/sprint_menu_component.html.erb | 2 ++ .../backlogs/sprint_menu_component.rb | 20 ++++++++++++++++ modules/backlogs/config/locales/en.yml | 1 + .../backlogs/sprint_menu_component_spec.rb | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb index 4cb3d7433cb..f95922f4f96 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb @@ -41,12 +41,14 @@ See COPYRIGHT and LICENSE files for more details. label: t(".action_menu.start_sprint"), tag: :button, href: start_project_sprint_path(project, sprint), + disabled: disable_start_sprint_action?, form_arguments: { method: :patch, data: { turbo: false } } ) do |item| item.with_leading_visual_icon(icon: :play) + item.with_description(start_sprint_action_description) if start_sprint_action_description.present? end end diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb index 39d96102b7e..5e6ec89dbca 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb @@ -68,6 +68,18 @@ module Backlogs scrum_projects_active? && sprint.in_planning? && user_allowed?(:start_complete_sprint) end + def disable_start_sprint_action? + scrum_projects_active? && + sprint.in_planning? && + project_has_another_active_sprint? + end + + def start_sprint_action_description + return unless disable_start_sprint_action? + + t(".action_menu.start_sprint_disabled_description") + end + def user_allowed?(permission) current_user.allowed_in_project?(permission, project) end @@ -75,5 +87,13 @@ module Backlogs def available_story_types @available_story_types ||= story_types & project.types end + + def project_has_another_active_sprint? + @project_has_another_active_sprint ||= Agile::Sprint + .for_project(project) + .where(status: "active") + .where.not(id: sprint.id) + .exists? + end end end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index d44babee6ea..eadef55f7bf 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -142,6 +142,7 @@ en: label_actions: "Sprint actions" action_menu: start_sprint: "Start sprint" + start_sprint_disabled_description: "Another sprint is already active." edit_sprint: "Edit sprint" new_story: "New story" stories_tasks: "Stories/Tasks" diff --git a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb index 217320ada90..5a49216fc89 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb @@ -151,6 +151,29 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do visible: :hidden ) end + + context "when another sprint is already active" do + let!(:active_sprint) do + create(:agile_sprint, + project:, + name: "Sprint 2", + start_date: Date.yesterday, + finish_date: Date.tomorrow, + status: "active") + end + + it "shows Start sprint disabled with a description" do + render_component + + expect(menu_items.first).to include("Start sprint") + expect(page).to have_selector( + :menuitem, + text: "Start sprint", + disabled: true + ) + expect(page).to have_text("Another sprint is already active.") + end + end end context "when the sprint is in planning and the user cannot start it" do From a83f0e8a4b5812ca68b6a45a2cb2f4e192a65378 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 07:56:17 -0500 Subject: [PATCH 146/435] Add Finish sprint flow for symmetry Add a matching Finish sprint action, route, and service so active sprints can be completed through the sprint menu. For now, the finish flow only updates sprint status and leaves board behavior unchanged. --- .../backlogs/sprint_menu_component.html.erb | 14 +++ .../backlogs/sprint_menu_component.rb | 4 + .../app/controllers/rb_sprints_controller.rb | 46 +++++++++- .../app/services/sprints/finish_service.rb | 69 +++++++++++++++ modules/backlogs/config/locales/en.yml | 3 + modules/backlogs/config/routes.rb | 1 + .../lib/open_project/backlogs/engine.rb | 2 +- .../backlogs/sprint_menu_component_spec.rb | 11 ++- .../controllers/rb_sprints_controller_spec.rb | 85 +++++++++++++++++++ .../spec/features/sprints/edit_spec.rb | 32 +++++++ .../open_project/backlogs/permissions_spec.rb | 4 + .../spec/routing/rb_sprints_routing_spec.rb | 9 ++ .../services/sprints/finish_service_spec.rb | 68 +++++++++++++++ 13 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 modules/backlogs/app/services/sprints/finish_service.rb create mode 100644 modules/backlogs/spec/services/sprints/finish_service_spec.rb diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb index f95922f4f96..6c70b409b0f 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.html.erb @@ -52,6 +52,20 @@ See COPYRIGHT and LICENSE files for more details. end end + if show_finish_sprint_action? + menu.with_item( + label: t(".action_menu.finish_sprint"), + tag: :button, + href: finish_project_sprint_path(project, sprint), + form_arguments: { + method: :patch, + data: { turbo: false } + } + ) do |item| + item.with_leading_visual_icon(icon: :check) + end + end + if user_allowed?(:create_sprints) menu.with_item( id: dom_target(sprint, :menu, :edit_sprint), diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb index 5e6ec89dbca..b90e79bce55 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb @@ -68,6 +68,10 @@ module Backlogs scrum_projects_active? && sprint.in_planning? && user_allowed?(:start_complete_sprint) end + def show_finish_sprint_action? + scrum_projects_active? && sprint.active? && user_allowed?(:start_complete_sprint) + end + def disable_start_sprint_action? scrum_projects_active? && sprint.in_planning? && diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index a0373f3a185..cde5a6aec85 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -36,13 +36,13 @@ class RbSprintsController < RbApplicationController create refresh_form update_agile_sprint].freeze - START_ACTIONS = %i[start].freeze + SPRINT_STATE_ACTIONS = %i[start finish].freeze skip_before_action :load_sprint_and_project, only: NEW_SPRINT_ACTIONS before_action :not_authorized_on_feature_flag_inactive, :load_project, - only: NEW_SPRINT_ACTIONS + START_ACTIONS + only: NEW_SPRINT_ACTIONS + SPRINT_STATE_ACTIONS def new_dialog call = Sprints::SetAttributesService.new( @@ -108,6 +108,7 @@ class RbSprintsController < RbApplicationController end def start + return render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? return render_404 unless @sprint.in_planning? result = start_sprint @@ -121,6 +122,20 @@ class RbSprintsController < RbApplicationController end end + def finish + return render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? + return render_404 unless @sprint.active? + + result = finish_sprint + + if result.success? + redirect_to backlogs_project_backlogs_path(@project), + notice: I18n.t(:notice_successful_update) + else + respond_with_finish_failure(message: finish_failure_message(result.message)) + end + end + def edit_name update_header_component_via_turbo_stream(state: :edit) respond_with_turbo_streams @@ -190,7 +205,7 @@ class RbSprintsController < RbApplicationController load_project @sprint = if OpenProject::FeatureDecisions.scrum_projects_active? && - (NEW_SPRINT_ACTIONS + START_ACTIONS).include?(action_name.to_sym) + (NEW_SPRINT_ACTIONS + SPRINT_STATE_ACTIONS).include?(action_name.to_sym) Agile::Sprint.for_project(@project).visible.find(params[:id]) else Sprint.visible.find(params[:id]) @@ -223,6 +238,12 @@ class RbSprintsController < RbApplicationController .call end + def finish_sprint + Sprints::FinishService + .new(user: current_user, model: @sprint) + .call + end + def respond_with_start_failure(message:) render_error_flash_message_via_turbo_stream(message:) @@ -240,6 +261,25 @@ class RbSprintsController < RbApplicationController I18n.t(:notice_unsuccessful_start) end end + + def respond_with_finish_failure(message:) + render_error_flash_message_via_turbo_stream(message:) + + respond_with_turbo_streams(status: :unprocessable_entity) do |format| + format.html do + redirect_back_or_to(backlogs_project_backlogs_path(@project), alert: message) + end + end + end + + def finish_failure_message(reason) + if reason.present? + I18n.t(:notice_unsuccessful_finish_with_reason, reason:) + else + I18n.t(:notice_unsuccessful_finish) + end + end + def not_authorized_on_feature_flag_inactive render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? end diff --git a/modules/backlogs/app/services/sprints/finish_service.rb b/modules/backlogs/app/services/sprints/finish_service.rb new file mode 100644 index 00000000000..da5daf10378 --- /dev/null +++ b/modules/backlogs/app/services/sprints/finish_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Sprints::FinishService < BaseServices::BaseCallable + include Shared::ServiceContext + + attr_reader :user, :model + + def initialize(user:, model:) + super() + @user = user + @model = model + end + + def perform + in_context(model, send_notifications: false) do + finish_sprint + end + end + + private + + def finish_sprint + return unsuccessful_finish_result unless model.active? + + model.completed! + + ServiceResult.success(result: model) + rescue ActiveRecord::RecordInvalid + unsuccessful_finish_result + end + + def unsuccessful_finish_result + ServiceResult.failure(result: model, + errors: model.errors, + message: unsuccessful_finish_message) + end + + def unsuccessful_finish_message + model.errors.full_messages.to_sentence if model.errors.any? + end +end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index eadef55f7bf..d24351ea55e 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -142,6 +142,7 @@ en: label_actions: "Sprint actions" action_menu: start_sprint: "Start sprint" + finish_sprint: "Finish sprint" start_sprint_disabled_description: "Another sprint is already active." edit_sprint: "Edit sprint" new_story: "New story" @@ -192,6 +193,8 @@ en: error_task_board_creation_failed: "The task board could not be created automatically." notice_unsuccessful_start: "The sprint could not be started." notice_unsuccessful_start_with_reason: "The sprint could not be started: %{reason}" + notice_unsuccessful_finish: "The sprint could not be completed." + notice_unsuccessful_finish_with_reason: "The sprint could not be completed: %{reason}" permission_create_sprints: "Create sprints" permission_manage_sprint_items: "Manage sprint items" diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index d1682157b5f..94fb4ce6db7 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -40,6 +40,7 @@ Rails.application.routes.draw do member do patch :start + patch :finish get :edit_dialog put :update_agile_sprint end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 710747c39ef..ab430ac7118 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -95,7 +95,7 @@ module OpenProject::Backlogs dependencies: :view_sprints permission :start_complete_sprint, - { rb_sprints: :start }, + { rb_sprints: %i[start finish] }, permissible_on: :project, require: :member, dependencies: %i[view_sprints manage_board_views], diff --git a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb index 5a49216fc89..c1c4c8a1b49 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb @@ -39,6 +39,7 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do let(:user) { create(:user) } let(:permissions) { [] } let(:start_sprint_path) { Rails.application.routes.url_helpers.start_project_sprint_path(project, sprint) } + let(:finish_sprint_path) { Rails.application.routes.url_helpers.finish_project_sprint_path(project, sprint) } before do allow(Setting) @@ -127,10 +128,18 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do finish_date: Date.tomorrow, status: "active") end + let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } - it "shows Task board after Stories/Tasks" do + it "shows Finish sprint first and Task board after Stories/Tasks" do render_component + expect(menu_items.first).to eq("Finish sprint") + expect(page).to have_octicon(:check) + expect(page).to have_css( + "form[action='#{finish_sprint_path}'][data-turbo='false'] " \ + "input[name='_method'][value='patch']", + visible: :hidden + ) expect(menu_items).to include("Stories/Tasks", "Task board") expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") end diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index 97fff7849ea..67ddf34e52f 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -456,6 +456,91 @@ RSpec.describe RbSprintsController do end end + describe "PATCH #finish" do + let!(:sprint) { create(:agile_sprint, project:, status: "active") } + let(:request_params) { { project_id: project.id, id: sprint.id } } + let(:service_result) do + ServiceResult.success( + result: sprint.tap { |finished_sprint| finished_sprint.status = "completed" } + ) + end + let(:service) { instance_double(Sprints::FinishService, call: service_result) } + + before do + allow(Sprints::FinishService) + .to receive(:new) + .with(user:, model: sprint) + .and_return(service) + end + + context "with the feature flag inactive" do + it "responds with not found" do + patch :finish, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:not_found) + end + end + + context "with the feature flag active", with_flag: { scrum_projects: true } do + it "finishes the sprint and redirects to the backlog", :aggregate_failures do + patch :finish, params: request_params + + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + expect(flash[:notice]).to eq(I18n.t(:notice_successful_update)) + expect(service).to have_received(:call) + end + + context "when finishing fails" do + let(:service_result) { ServiceResult.failure(message: "something went wrong") } + + it "redirects back to the backlog", :aggregate_failures do + patch :finish, params: request_params + + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + expect(flash[:alert]).to eq( + I18n.t(:notice_unsuccessful_finish_with_reason, reason: "something went wrong") + ) + expect(service).to have_received(:call) + end + end + + context "when finishing fails without an explicit message" do + let(:service_result) { ServiceResult.failure } + + it "redirects back with the default finish failure message", :aggregate_failures do + patch :finish, params: request_params + + expect(response).to redirect_to(backlogs_project_backlogs_path(project)) + expect(flash[:alert]).to eq(I18n.t(:notice_unsuccessful_finish)) + expect(service).to have_received(:call) + end + end + + context "without the 'start_complete_sprint' permission" do + let(:permissions) { all_permissions - [:start_complete_sprint] } + + it "responds with forbidden" do + patch :finish, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:forbidden) + end + end + + context "when the sprint is already completed" do + let!(:sprint) { create(:agile_sprint, project:, status: "completed") } + + it "responds with not found" do + patch :finish, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:not_found) + end + end + end + end + describe "GET #refresh_form" do context "with the feature flag inactive" do it "responds with forbidden" do diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb index 460b24e7d9f..1ab98e92fe0 100644 --- a/modules/backlogs/spec/features/sprints/edit_spec.rb +++ b/modules/backlogs/spec/features/sprints/edit_spec.rb @@ -175,6 +175,38 @@ RSpec.describe "Edit", :js do backlogs_page.expect_sprint_names_in_order("Changed name", second_sprint.name) end + context "when the sprint is active" do + let!(:first_sprint) do + create(:agile_sprint, + project:, + status: "active", + start_date: Date.new(2025, 9, 5), + finish_date: Date.new(2025, 9, 15)) + end + + let!(:second_sprint) do + create(:agile_sprint, + project:, + start_date: Date.new(2025, 9, 16), + finish_date: Date.new(2025, 9, 26)) + end + + let!(:task_board) { create(:board_grid_with_query, project:, linked: first_sprint) } + + it "finishes the sprint and returns to the backlog" do + backlogs_page.within_sprint_menu(first_sprint) do |menu| + expect(menu).to have_selector :menuitem, "Finish sprint" + expect(menu).to have_css "form[action='#{finish_project_sprint_path(project, first_sprint)}'][data-turbo='false']" + menu.find(:button, "Finish sprint").click + end + + backlogs_page.expect_current_path + expect_and_dismiss_flash type: :success, message: "Successful update." + expect(first_sprint.reload).to be_completed + backlogs_page.expect_sprint_names_in_order(second_sprint.name) + end + end + context "when lacking the 'manage_sprint_items' permission" do let(:permissions) { all_permissions - %i[manage_sprint_items] } diff --git a/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb b/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb index 26e31ebc4ca..8ba788fdec0 100644 --- a/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb +++ b/modules/backlogs/spec/lib/open_project/backlogs/permissions_spec.rb @@ -62,6 +62,10 @@ RSpec.describe OpenProject::AccessControl, "Backlogs module permissions" do # ru expect(subject.dependencies).to contain_exactly(:view_sprints, :manage_board_views) end + it "covers both start and finish sprint actions" do + expect(subject.controller_actions).to include("rb_sprints/start", "rb_sprints/finish") + end + context "when scrum_projects feature flag is active", with_flag: { scrum_projects: true } do it { is_expected.to be_visible } end diff --git a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb index c07bf7c2c48..0f374f523bf 100644 --- a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb @@ -65,5 +65,14 @@ RSpec.describe RbSprintsController do id: "21" ) } + + it { + expect(patch("/projects/project_42/sprints/21/finish")).to route_to( + controller: "rb_sprints", + action: "finish", + project_id: "project_42", + id: "21" + ) + } end end diff --git a/modules/backlogs/spec/services/sprints/finish_service_spec.rb b/modules/backlogs/spec/services/sprints/finish_service_spec.rb new file mode 100644 index 00000000000..727b2a85294 --- /dev/null +++ b/modules/backlogs/spec/services/sprints/finish_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Sprints::FinishService do + let(:user) { create(:admin) } + let(:project) { create(:project) } + let(:sprint) { create(:agile_sprint, project:, status:) } + let(:status) { "active" } + let(:instance) { described_class.new(user:, model: sprint) } + + subject(:result) { instance.call } + + context "when the sprint is active" do + it "completes the sprint", :aggregate_failures do + expect(result).to be_success + expect(sprint.reload).to be_completed + end + end + + context "when the sprint is in planning" do + let(:status) { "in_planning" } + + it "returns failure and leaves the sprint unchanged", :aggregate_failures do + expect(result).not_to be_success + expect(result.message).to be_blank + expect(sprint.reload).to be_in_planning + end + end + + context "when the sprint is already completed" do + let(:status) { "completed" } + + it "returns failure and leaves the sprint unchanged", :aggregate_failures do + expect(result).not_to be_success + expect(result.message).to be_blank + expect(sprint.reload).to be_completed + end + end +end From bf86c27aa8ed151dae81b0c5b61d513c61376aea Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 11:43:29 -0500 Subject: [PATCH 147/435] Simplify sprint component assumptions Simplify the scrum sprint component predicates and align the component/controller specs with the sprint-specific rendering paths. --- .../components/backlogs/sprint_menu_component.rb | 14 ++++---------- .../components/backlogs/sprint_component_spec.rb | 2 +- .../backlogs/sprint_header_component_spec.rb | 2 +- .../backlogs/sprint_menu_component_spec.rb | 11 +---------- .../spec/controllers/rb_stories_controller_spec.rb | 6 +++--- 5 files changed, 10 insertions(+), 25 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb index b90e79bce55..7ae7db35d69 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb @@ -56,26 +56,20 @@ module Backlogs private - def scrum_projects_active? - OpenProject::FeatureDecisions.scrum_projects_active? - end - def show_task_board_link? - !scrum_projects_active? || !sprint.in_planning? + !sprint.in_planning? end def show_start_sprint_action? - scrum_projects_active? && sprint.in_planning? && user_allowed?(:start_complete_sprint) + sprint.in_planning? && user_allowed?(:start_complete_sprint) end def show_finish_sprint_action? - scrum_projects_active? && sprint.active? && user_allowed?(:start_complete_sprint) + sprint.active? && user_allowed?(:start_complete_sprint) end def disable_start_sprint_action? - scrum_projects_active? && - sprint.in_planning? && - project_has_another_active_sprint? + sprint.in_planning? && project_has_another_active_sprint? end def start_sprint_action_description diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index bc56e0db62e..2dc14e060bf 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::SprintComponent, type: :component do +RSpec.describe Backlogs::SprintComponent, type: :component, with_flag: { scrum_projects: true } do include Rails.application.routes.url_helpers shared_let(:type_feature) { create(:type_feature) } diff --git a/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb index 2318fd41172..ea79c4428be 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::SprintHeaderComponent, type: :component do +RSpec.describe Backlogs::SprintHeaderComponent, type: :component, with_flag: { scrum_projects: true } do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } shared_let(:default_status) { create(:default_status) } diff --git a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb index c1c4c8a1b49..aa1378cf694 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::SprintMenuComponent, type: :component do +RSpec.describe Backlogs::SprintMenuComponent, type: :component, with_flag: { scrum_projects: true } do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } @@ -109,15 +109,6 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do describe "task board actions" do let(:permissions) { %i[view_sprints view_work_packages] } - context "with the feature flag inactive", with_flag: { scrum_projects: false } do - it "shows Task board after Stories/Tasks" do - render_component - - expect(menu_items).to include("Stories/Tasks", "Task board") - expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do context "when the sprint is active" do let(:sprint) do diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb index 6fd8581ca2c..35e4ef5fc3b 100644 --- a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -126,7 +126,7 @@ RSpec.describe RbStoriesController do end end - context "with an Agile::Sprint as target" do + context "with an Agile::Sprint as target", with_flag: { scrum_projects: true } do let(:agile_sprint) { create(:agile_sprint, name: "Agile Sprint 1", project:) } it "responds with success and moves story to Agile::Sprint, removing the association to the version", :aggregate_failures do @@ -225,7 +225,7 @@ RSpec.describe RbStoriesController do let(:agile_sprint) { create(:agile_sprint, name: "Agile Sprint 1", project:) } let(:story_in_agile_sprint) { create(:work_package, status:, sprint: agile_sprint, project:) } - context "with another Agile::Sprint as target" do + context "with another Agile::Sprint as target", with_flag: { scrum_projects: true } do let(:other_agile_sprint) { create(:agile_sprint, name: "Agile Sprint 2", project:) } it "responds with success and moves story to another Agile::Sprint", :aggregate_failures do @@ -282,7 +282,7 @@ RSpec.describe RbStoriesController do end end - context "with a Sprint (Version) as target" do + context "with a Sprint (Version) as target", with_flag: { scrum_projects: true } do it "responds with success and moves story to Sprint", :aggregate_failures do put :move, params: { project_id: project.id, From 9de770b73d81ef9adcc90d7a6dce63475a622699 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 11:50:52 -0500 Subject: [PATCH 148/435] Add sprint-specific success notices --- modules/backlogs/app/controllers/rb_sprints_controller.rb | 4 ++-- modules/backlogs/config/locales/en.yml | 2 ++ .../backlogs/spec/controllers/rb_sprints_controller_spec.rb | 2 +- modules/backlogs/spec/features/sprints/edit_spec.rb | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index cde5a6aec85..22695e869e8 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -116,7 +116,7 @@ class RbSprintsController < RbApplicationController if result.success? @sprint = result.result redirect_to project_work_package_board_path(@project, @sprint.task_board), - notice: I18n.t(:notice_successful_update) + notice: I18n.t(:notice_successful_start) else respond_with_start_failure(message: start_failure_message(result.message)) end @@ -130,7 +130,7 @@ class RbSprintsController < RbApplicationController if result.success? redirect_to backlogs_project_backlogs_path(@project), - notice: I18n.t(:notice_successful_update) + notice: I18n.t(:notice_successful_finish) else respond_with_finish_failure(message: finish_failure_message(result.message)) end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index d24351ea55e..efd76b5cf48 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -191,6 +191,8 @@ en: label_sprint_new: "New sprint" label_task_board: "Task board" error_task_board_creation_failed: "The task board could not be created automatically." + notice_successful_start: "The sprint was started." + notice_successful_finish: "The sprint was completed." notice_unsuccessful_start: "The sprint could not be started." notice_unsuccessful_start_with_reason: "The sprint could not be started: %{reason}" notice_unsuccessful_finish: "The sprint could not be completed." diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index 67ddf34e52f..e117ff3a585 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -487,7 +487,7 @@ RSpec.describe RbSprintsController do patch :finish, params: request_params expect(response).to redirect_to(backlogs_project_backlogs_path(project)) - expect(flash[:notice]).to eq(I18n.t(:notice_successful_update)) + expect(flash[:notice]).to eq(I18n.t(:notice_successful_finish)) expect(service).to have_received(:call) end diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb index 1ab98e92fe0..8e3d7211a6d 100644 --- a/modules/backlogs/spec/features/sprints/edit_spec.rb +++ b/modules/backlogs/spec/features/sprints/edit_spec.rb @@ -153,7 +153,7 @@ RSpec.describe "Edit", :js do it "starts the sprint and redirects to the board" do backlogs_page.click_in_sprint_menu(first_sprint, "Start sprint") - expect_and_dismiss_flash type: :success, message: "Successful update." + expect_and_dismiss_flash type: :success, message: "The sprint was started." expect(page).to have_current_path(%r{/projects/#{project.identifier}/boards/\d+}) expect(first_sprint.reload.task_board).to be_present @@ -201,7 +201,7 @@ RSpec.describe "Edit", :js do end backlogs_page.expect_current_path - expect_and_dismiss_flash type: :success, message: "Successful update." + expect_and_dismiss_flash type: :success, message: "The sprint was completed." expect(first_sprint.reload).to be_completed backlogs_page.expect_sprint_names_in_order(second_sprint.name) end From 411cc24d6d3d698580826be71b01d37975e815a9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 12:38:55 -0500 Subject: [PATCH 149/435] Fix sprint filter schema dependency Add a sprint-specific query schema dependency representer so the board page can render filters without crashing after sprint start. This fixes the CI failure in the sprint start feature spec caused by SprintFilter not mapping to any dependency representer. --- .../sprint_filter_dependency_representer.rb | 47 +++++++++++++++++++ .../backlogs/sprint_filter_spec.rb | 9 ++++ 2 files changed, 56 insertions(+) create mode 100644 lib/api/v3/queries/schemas/sprint_filter_dependency_representer.rb diff --git a/lib/api/v3/queries/schemas/sprint_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/sprint_filter_dependency_representer.rb new file mode 100644 index 00000000000..071e9872e34 --- /dev/null +++ b/lib/api/v3/queries/schemas/sprint_filter_dependency_representer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module Queries + module Schemas + class SprintFilterDependencyRepresenter < FilterDependencyRepresenter + def href_callback; end + + private + + def type + "[]Integer" + end + end + end + end + end +end diff --git a/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb b/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb index 23c0f2261d5..f7bca89e3a8 100644 --- a/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb +++ b/modules/backlogs/spec/lib/open_project/backlogs/sprint_filter_spec.rb @@ -101,6 +101,15 @@ RSpec.describe OpenProject::Backlogs::SprintFilter do end end + describe "dependency representer" do + it "maps to the sprint dependency representer" do + dependency = API::V3::Queries::Schemas::FilterDependencyRepresenterFactory.create(instance, + Queries::Operators::Equals) + + expect(dependency).to be_a(API::V3::Queries::Schemas::SprintFilterDependencyRepresenter) + end + end + describe "#value_objects" do let(:sprint1) { build_stubbed(:agile_sprint) } let(:sprint2) { build_stubbed(:agile_sprint) } From 40ac7eb523056942f516cfa1136fa2926e684318 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 13:18:05 -0500 Subject: [PATCH 150/435] [#73137] Add active sprint constraint Add a database-level unique partial index to enforce one active sprint per project, and handle the resulting race in Sprints::StartService. https://community.openproject.org/wp/73137 --- .../app/services/sprints/start_service.rb | 9 ++++ ...70000_add_uniqueness_for_active_sprints.rb | 44 +++++++++++++++++++ .../services/sprints/start_service_spec.rb | 16 +++++++ 3 files changed, 69 insertions(+) create mode 100644 modules/backlogs/db/migrate/20260313170000_add_uniqueness_for_active_sprints.rb diff --git a/modules/backlogs/app/services/sprints/start_service.rb b/modules/backlogs/app/services/sprints/start_service.rb index a8f75df152e..7fbb71bee58 100644 --- a/modules/backlogs/app/services/sprints/start_service.rb +++ b/modules/backlogs/app/services/sprints/start_service.rb @@ -58,6 +58,9 @@ class Sprints::StartService < BaseServices::BaseCallable ServiceResult.success(result: model) rescue ActiveRecord::RecordInvalid unsuccessful_start_result + rescue ActiveRecord::RecordNotUnique + add_only_one_active_sprint_error + unsuccessful_start_result end def unsuccessful_start_result @@ -77,4 +80,10 @@ class Sprints::StartService < BaseServices::BaseCallable def unsuccessful_start_message model.errors.full_messages.to_sentence if model.errors.any? end + + def add_only_one_active_sprint_error + return if model.errors.added?(:status, :only_one_active_sprint_allowed) + + model.errors.add(:status, :only_one_active_sprint_allowed) + end end diff --git a/modules/backlogs/db/migrate/20260313170000_add_uniqueness_for_active_sprints.rb b/modules/backlogs/db/migrate/20260313170000_add_uniqueness_for_active_sprints.rb new file mode 100644 index 00000000000..aad670390b6 --- /dev/null +++ b/modules/backlogs/db/migrate/20260313170000_add_uniqueness_for_active_sprints.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class AddUniquenessForActiveSprints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + add_index :sprints, :project_id, unique: true, + where: "status = 'active'", + algorithm: :concurrently, + name: "index_sprints_on_project_id_when_active" + end + + def down + remove_index :sprints, name: "index_sprints_on_project_id_when_active", algorithm: :concurrently + end +end diff --git a/modules/backlogs/spec/services/sprints/start_service_spec.rb b/modules/backlogs/spec/services/sprints/start_service_spec.rb index deb4fccb588..2bb9d73587c 100644 --- a/modules/backlogs/spec/services/sprints/start_service_spec.rb +++ b/modules/backlogs/spec/services/sprints/start_service_spec.rb @@ -99,6 +99,22 @@ RSpec.describe Sprints::StartService do end end + context "when the database unique constraint rejects sprint activation" do + before do + allow(sprint) + .to receive(:active!) + .and_raise(ActiveRecord::RecordNotUnique) + end + + it "returns failure with the active sprint error", :aggregate_failures do + expect(result).not_to be_success + expect(result.errors[:status]).to include("only one active sprint is allowed per project.") + expect(result.message).to eq(sprint.errors.full_messages.to_sentence) + expect(sprint.reload).to be_in_planning + expect(sprint.task_board).to be_nil + end + end + context "when the sprint is already active" do let(:status) { "active" } From 45ef125911d91a37201dac79b3506401d9ae364a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 14:24:02 -0700 Subject: [PATCH 151/435] Use enum scope in sprint validation Replace the raw active-status predicate in the sprint uniqueness validation with the enum-defined active scope. --- modules/backlogs/app/models/agile/sprint.rb | 27 ++++++--------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index 69500c11e4c..ffdbd625069 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -66,8 +66,13 @@ module Agile validates :finish_date, comparison: { greater_than_or_equal_to: :start_date }, if: :start_date? - - validate :validate_only_one_active_sprint_per_project + validates :status, + uniqueness: { + scope: :project_id, + conditions: -> { active }, + message: :only_one_active_sprint_allowed + }, + if: :active? def date_range_set? start_date? && finish_date? @@ -86,23 +91,5 @@ module Agile def task_board? task_board.present? end - - private - - # TODO: consider moving this validation to the database level to ensure data integrity. - # Doing this in Rails can lead to race conditions. Revisit this topic once the sharing - # logic has been fully specified. - def validate_only_one_active_sprint_per_project - return if !active? || project_id.blank? - - existing_active_sprint = self.class - .where(project_id:, status: "active") - .where.not(id:) - .exists? - - if existing_active_sprint - errors.add(:status, :only_one_active_sprint_allowed) - end - end end end From 71ce95497a7a00ebabdd93fbccc86526f8eee559 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Mar 2026 16:29:45 -0700 Subject: [PATCH 152/435] Move scrum guards into routes Use Constraints::FeatureDecision for the scrum-only sprint and backlog sharing routes, and move the inactive-flag expectations from controller specs into routing specs. https://github.com/opf/openproject/pull/22086 --- .../settings/backlog_sharings_controller.rb | 6 -- .../app/controllers/rb_sprints_controller.rb | 13 +-- modules/backlogs/config/routes.rb | 48 ++++++----- .../backlog_sharings_controller_spec.rb | 16 ---- .../controllers/rb_sprints_controller_spec.rb | 63 -------------- .../settings/backlog_sharing_settings_spec.rb | 6 +- .../settings/backlog_sharings_routing_spec.rb | 30 +++++++ .../spec/routing/rb_sprints_routing_spec.rb | 86 +++++++++++++++---- .../spec/routing/rb_stories_routing_spec.rb | 26 ++++-- 9 files changed, 147 insertions(+), 147 deletions(-) create mode 100644 modules/backlogs/spec/routing/projects/settings/backlog_sharings_routing_spec.rb diff --git a/modules/backlogs/app/controllers/projects/settings/backlog_sharings_controller.rb b/modules/backlogs/app/controllers/projects/settings/backlog_sharings_controller.rb index dc547958a58..850677bbfa2 100644 --- a/modules/backlogs/app/controllers/projects/settings/backlog_sharings_controller.rb +++ b/modules/backlogs/app/controllers/projects/settings/backlog_sharings_controller.rb @@ -31,8 +31,6 @@ class Projects::Settings::BacklogSharingsController < Projects::SettingsController menu_item :settings_backlogs - before_action :check_scrum_projects_feature_flag - def show; end def update @@ -51,10 +49,6 @@ class Projects::Settings::BacklogSharingsController < Projects::SettingsControll private - def check_scrum_projects_feature_flag - render_404 unless OpenProject::FeatureDecisions.scrum_projects_active? - end - def backlog_settings_params params.expect(project: %i[sprint_sharing]) end diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index 22695e869e8..afb5b2611c8 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -40,9 +40,7 @@ class RbSprintsController < RbApplicationController skip_before_action :load_sprint_and_project, only: NEW_SPRINT_ACTIONS - before_action :not_authorized_on_feature_flag_inactive, - :load_project, - only: NEW_SPRINT_ACTIONS + SPRINT_STATE_ACTIONS + before_action :load_project, only: NEW_SPRINT_ACTIONS + SPRINT_STATE_ACTIONS def new_dialog call = Sprints::SetAttributesService.new( @@ -108,7 +106,6 @@ class RbSprintsController < RbApplicationController end def start - return render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? return render_404 unless @sprint.in_planning? result = start_sprint @@ -123,7 +120,6 @@ class RbSprintsController < RbApplicationController end def finish - return render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? return render_404 unless @sprint.active? result = finish_sprint @@ -204,8 +200,7 @@ class RbSprintsController < RbApplicationController def load_sprint_and_project load_project - @sprint = if OpenProject::FeatureDecisions.scrum_projects_active? && - (NEW_SPRINT_ACTIONS + SPRINT_STATE_ACTIONS).include?(action_name.to_sym) + @sprint = if (NEW_SPRINT_ACTIONS + SPRINT_STATE_ACTIONS).include?(action_name.to_sym) Agile::Sprint.for_project(@project).visible.find(params[:id]) else Sprint.visible.find(params[:id]) @@ -279,8 +274,4 @@ class RbSprintsController < RbApplicationController I18n.t(:notice_unsuccessful_finish) end end - - def not_authorized_on_feature_flag_inactive - render_403 unless OpenProject::FeatureDecisions.scrum_projects_active? - end end diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index 94fb4ce6db7..84634d051fc 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -29,26 +29,34 @@ #++ Rails.application.routes.draw do - # Routes for the new Agile::Sprint - # Scoped under projects for permissions: - resources :projects, only: [] do - resources :sprints, controller: :rb_sprints, only: %i[create] do - collection do - get :new_dialog - get :refresh_form - end - - member do - patch :start - patch :finish - get :edit_dialog - put :update_agile_sprint - end - - resources :stories, controller: :rb_stories, only: [] do - member do - put :move + constraints(Constraints::FeatureDecision.new(:scrum_projects)) do + # Routes for the new Agile::Sprint + # Scoped under projects for permissions: + resources :projects, only: [] do + resources :sprints, controller: :rb_sprints, only: %i[create] do + collection do + get :new_dialog + get :refresh_form end + + member do + patch :start + patch :finish + get :edit_dialog + put :update_agile_sprint + end + + resources :stories, controller: :rb_stories, only: [] do + member do + put :move + end + end + end + end + + scope "projects/:project_id", as: "project", module: "projects" do + namespace "settings" do + resource :backlog_sharing, only: %i[show update] end end end @@ -98,8 +106,6 @@ Rails.application.routes.draw do scope "projects/:project_id", as: "project", module: "projects" do namespace "settings" do - resource :backlog_sharing, only: %i[show update] - resource :backlogs, only: %i[show update] do member do post "rebuild_positions" => "backlogs#rebuild_positions" diff --git a/modules/backlogs/spec/controllers/projects/settings/backlog_sharings_controller_spec.rb b/modules/backlogs/spec/controllers/projects/settings/backlog_sharings_controller_spec.rb index 100e317df11..fe43d163b2a 100644 --- a/modules/backlogs/spec/controllers/projects/settings/backlog_sharings_controller_spec.rb +++ b/modules/backlogs/spec/controllers/projects/settings/backlog_sharings_controller_spec.rb @@ -50,20 +50,4 @@ RSpec.describe Projects::Settings::BacklogSharingsController, with_flag: { scrum end end end - - context "when scrum_projects feature flag is inactive", with_flag: { scrum_projects: false } do - let(:project) { build_stubbed(:project) } - - it "returns 404 for show" do - get :show, params: { project_id: project.identifier } - - expect(response).to have_http_status(:not_found) - end - - it "returns 404 for update" do - patch :update, params: { project_id: project.identifier, project: { sprint_sharing: "no_sharing" } } - - expect(response).to have_http_status(:not_found) - end - end end diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index e117ff3a585..3445b87268c 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -174,15 +174,6 @@ RSpec.describe RbSprintsController do end describe "GET #new_dialog" do - context "with the feature flag inactive" do - it "responds with forbidden" do - get :new_dialog, params: { project_id: project.id }, format: :turbo_stream - - expect(response).not_to be_successful - expect(response).to have_http_status :forbidden - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do it "responds with success", :aggregate_failures do get :new_dialog, params: { project_id: project.id }, format: :turbo_stream @@ -209,15 +200,6 @@ RSpec.describe RbSprintsController do describe "GET #edit_dialog" do let!(:sprint) { create(:agile_sprint, project:) } - context "with the feature flag inactive" do - it "responds with forbidden" do - get :edit_dialog, params: { project_id: project.id, id: sprint.id }, format: :turbo_stream - - expect(response).not_to be_successful - expect(response).to have_http_status :forbidden - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do it "responds with success", :aggregate_failures do get :edit_dialog, params: { project_id: project.id, id: sprint.id }, format: :turbo_stream @@ -243,15 +225,6 @@ RSpec.describe RbSprintsController do end describe "POST #create" do - context "with the feature flag inactive" do - it "responds with forbidden" do - post :create, params: { project_id: project.id }, format: :turbo_stream - - expect(response).not_to be_successful - expect(response).to have_http_status :forbidden - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do let(:params) do { @@ -288,15 +261,6 @@ RSpec.describe RbSprintsController do describe "PUT #update_agile_sprint" do let!(:sprint) { create(:agile_sprint, name: "Original sprint name", project:) } - context "with the feature flag inactive" do - it "responds with forbidden" do - put :update_agile_sprint, params: { id: sprint.id, project_id: project.id }, format: :turbo_stream - - expect(response).not_to be_successful - expect(response).to have_http_status :forbidden - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do let(:params) do { @@ -344,15 +308,6 @@ RSpec.describe RbSprintsController do .and_return(service) end - context "with the feature flag inactive" do - it "responds with not found" do - patch :start, params: request_params, format: :turbo_stream - - expect(response).not_to be_successful - expect(response).to have_http_status(:not_found) - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do context "when a board already exists" do let!(:existing_board) do @@ -473,15 +428,6 @@ RSpec.describe RbSprintsController do .and_return(service) end - context "with the feature flag inactive" do - it "responds with not found" do - patch :finish, params: request_params - - expect(response).not_to be_successful - expect(response).to have_http_status(:not_found) - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do it "finishes the sprint and redirects to the backlog", :aggregate_failures do patch :finish, params: request_params @@ -542,15 +488,6 @@ RSpec.describe RbSprintsController do end describe "GET #refresh_form" do - context "with the feature flag inactive" do - it "responds with forbidden" do - get :refresh_form, params: { project_id: project.id }, format: :turbo_stream - - expect(response).not_to be_successful - expect(response).to have_http_status :forbidden - end - end - context "with the feature flag active", with_flag: { scrum_projects: true } do let(:params) do { diff --git a/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb b/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb index 33ad5f37a6b..f5f6654b01f 100644 --- a/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb +++ b/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb @@ -143,15 +143,11 @@ RSpec.describe "Backlogs project settings sprint sharing", :js, with_flag: { scr end context "when scrum_projects feature flag is inactive", with_flag: { scrum_projects: false } do - it "does not show the sharing tab and returns 404 on direct access" do + it "does not show the sharing tab" do visit project_settings_backlogs_path(project) expect(page).to have_heading(I18n.t(:label_backlogs)) expect(page).to have_no_link(I18n.t("backlogs.sharing")) - - visit project_settings_backlog_sharing_path(project) - - expect(page).to have_text(I18n.t(:notice_file_not_found)) end end end diff --git a/modules/backlogs/spec/routing/projects/settings/backlog_sharings_routing_spec.rb b/modules/backlogs/spec/routing/projects/settings/backlog_sharings_routing_spec.rb new file mode 100644 index 00000000000..f24881f9d03 --- /dev/null +++ b/modules/backlogs/spec/routing/projects/settings/backlog_sharings_routing_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Projects::Settings::BacklogSharingsController do + describe "routing" do + context "with the feature flag active", with_flag: { scrum_projects: true } do + it { + expect(get("/projects/project_42/settings/backlog_sharing")).to route_to( + controller: "projects/settings/backlog_sharings", + action: "show", + project_id: "project_42" + ) + } + + it { + expect(patch("/projects/project_42/settings/backlog_sharing")).to route_to( + controller: "projects/settings/backlog_sharings", + action: "update", + project_id: "project_42" + ) + } + end + + context "with the feature flag inactive", with_flag: { scrum_projects: false } do + it { expect(get("/projects/project_42/settings/backlog_sharing")).not_to be_routable } + it { expect(patch("/projects/project_42/settings/backlog_sharing")).not_to be_routable } + end + end +end diff --git a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb index 0f374f523bf..2d1adee8428 100644 --- a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb @@ -57,22 +57,76 @@ RSpec.describe RbSprintsController do id: "21") } - it { - expect(patch("/projects/project_42/sprints/21/start")).to route_to( - controller: "rb_sprints", - action: "start", - project_id: "project_42", - id: "21" - ) - } + context "with the feature flag active", with_flag: { scrum_projects: true } do + it { + expect(get("/projects/project_42/sprints/new_dialog")).to route_to( + controller: "rb_sprints", + action: "new_dialog", + project_id: "project_42" + ) + } - it { - expect(patch("/projects/project_42/sprints/21/finish")).to route_to( - controller: "rb_sprints", - action: "finish", - project_id: "project_42", - id: "21" - ) - } + it { + expect(get("/projects/project_42/sprints/refresh_form")).to route_to( + controller: "rb_sprints", + action: "refresh_form", + project_id: "project_42" + ) + } + + it { + expect(post("/projects/project_42/sprints")).to route_to( + controller: "rb_sprints", + action: "create", + project_id: "project_42" + ) + } + + it { + expect(get("/projects/project_42/sprints/21/edit_dialog")).to route_to( + controller: "rb_sprints", + action: "edit_dialog", + project_id: "project_42", + id: "21" + ) + } + + it { + expect(put("/projects/project_42/sprints/21/update_agile_sprint")).to route_to( + controller: "rb_sprints", + action: "update_agile_sprint", + project_id: "project_42", + id: "21" + ) + } + + it { + expect(patch("/projects/project_42/sprints/21/start")).to route_to( + controller: "rb_sprints", + action: "start", + project_id: "project_42", + id: "21" + ) + } + + it { + expect(patch("/projects/project_42/sprints/21/finish")).to route_to( + controller: "rb_sprints", + action: "finish", + project_id: "project_42", + id: "21" + ) + } + end + + context "with the feature flag inactive", with_flag: { scrum_projects: false } do + it { expect(get("/projects/project_42/sprints/new_dialog")).not_to be_routable } + it { expect(get("/projects/project_42/sprints/refresh_form")).not_to be_routable } + it { expect(post("/projects/project_42/sprints")).not_to be_routable } + it { expect(get("/projects/project_42/sprints/21/edit_dialog")).not_to be_routable } + it { expect(put("/projects/project_42/sprints/21/update_agile_sprint")).not_to be_routable } + it { expect(patch("/projects/project_42/sprints/21/start")).not_to be_routable } + it { expect(patch("/projects/project_42/sprints/21/finish")).not_to be_routable } + end end end diff --git a/modules/backlogs/spec/routing/rb_stories_routing_spec.rb b/modules/backlogs/spec/routing/rb_stories_routing_spec.rb index e484c850e36..910795cf440 100644 --- a/modules/backlogs/spec/routing/rb_stories_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_stories_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -40,15 +42,21 @@ RSpec.describe RbStoriesController do ) } - it { - expect(put("/projects/project_42/sprints/21/stories/85/move")).to route_to( - controller: "rb_stories", - action: "move", - project_id: "project_42", - sprint_id: "21", - id: "85" - ) - } + context "with the feature flag active", with_flag: { scrum_projects: true } do + it { + expect(put("/projects/project_42/sprints/21/stories/85/move")).to route_to( + controller: "rb_stories", + action: "move", + project_id: "project_42", + sprint_id: "21", + id: "85" + ) + } + end + + context "with the feature flag inactive", with_flag: { scrum_projects: false } do + it { expect(put("/projects/project_42/sprints/21/stories/85/move")).not_to be_routable } + end it { expect(post("/projects/project_42/sprints/21/stories/85/reorder")).to route_to( From a0e2ded6d05472014e018037c23a5df53af7f55e Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Fri, 13 Mar 2026 09:10:18 +0100 Subject: [PATCH 153/435] Add button to trigger AMPF sync This is mostly intended for debugging purposes, when something with AMPF goes wrong. Under normal operation this button should not be necessary. --- config/i18n-tasks.yml | 11 ++++------- .../health_notifications_component.html.erb | 16 ++++++++++++++++ .../side_panel/health_notifications_component.rb | 7 ++++++- .../storages/admin/storages_controller.rb | 11 ++++++++++- modules/storages/config/locales/en.yml | 5 +++++ modules/storages/config/routes.rb | 1 + 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 1cc3a7f28dd..311fba7b0b7 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -58,15 +58,12 @@ search: ## Paths or `Find.find` patterns to search in: paths: # - app/ - - modules/storages/app/ + - modules/storages/app/ ## Root directories for relative keys resolution. - # relative_roots: - # - app/controllers - # - app/helpers - # - app/mailers - # - app/presenters - # - app/views + relative_roots: + - modules/storages/app/components + - modules/storages/app/views ## Directories where method names which should not be part of a relative key resolution. # By default, if a relative translation is used inside a method, the name of the method will be considered part of the resolved key. diff --git a/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.html.erb b/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.html.erb index 0e9592207f0..6043d985490 100644 --- a/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.html.erb +++ b/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.html.erb @@ -59,6 +59,22 @@ See COPYRIGHT and LICENSE files for more details. end end end + + notifications_container.with_row(mt: 2) do + if sync_pending? + render(Primer::OpenProject::InlineMessage.new(scheme: :success)) { t(".sync_queued") } + else + render(Primer::Beta::Button.new( + tag: :a, + scheme: :link, + data: { turbo_method: :post }, + href: ampf_sync_now_admin_settings_storage_path(@storage) + )) do |button| + button.with_leading_visual_icon(icon: :sync) + t(".sync_now") + end + end + end end end end diff --git a/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.rb b/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.rb index c5ab8c83acf..16295c3805d 100644 --- a/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.rb +++ b/modules/storages/app/components/storages/admin/side_panel/health_notifications_component.rb @@ -35,9 +35,10 @@ module Storages include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(storage:) + def initialize(storage:, sync_pending: false) super @storage = storage + @sync_pending = sync_pending end def render? @@ -57,6 +58,10 @@ module Storages end end + def sync_pending? + @sync_pending + end + # This method returns the health identifier, description and the time since when the error occurs in a # formatted manner. e.g. "Not found: Outbound request destination not found since 12/07/2023 03:45 PM" def formatted_health_reason diff --git a/modules/storages/app/controllers/storages/admin/storages_controller.rb b/modules/storages/app/controllers/storages/admin/storages_controller.rb index 3c48fb9e14d..c88af851abb 100644 --- a/modules/storages/app/controllers/storages/admin/storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/storages_controller.rb @@ -46,7 +46,7 @@ module Storages before_action :require_admin before_action :find_storage, only: %i[show_oauth_application destroy edit edit_host edit_storage_audience confirm_destroy update - change_health_notifications_enabled replace_oauth_application] + change_health_notifications_enabled replace_oauth_application ampf_sync_now] before_action :ensure_valid_wizard_parameters, only: [:new] before_action :require_ee_token, only: [:new] @@ -211,6 +211,15 @@ module Storages end end + def ampf_sync_now + ::Storages::AutomaticallyManagedStorageSyncJob.perform_later(@storage) + + update_via_turbo_stream( + component: ::Storages::Admin::SidePanel::HealthNotificationsComponent.new(storage: @storage, sync_pending: true) + ) + respond_with_turbo_streams + end + private def find_storage diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml index b2216df015b..51264c4e130 100644 --- a/modules/storages/config/locales/en.yml +++ b/modules/storages/config/locales/en.yml @@ -204,6 +204,11 @@ en: upload_link_service: not_found: The destination folder %{folder} could not be found on %{storage_name}. storages: + admin: + side_panel: + health_notifications_component: + sync_now: Sync now + sync_queued: Synchronization queued. buttons: done_continue: Done, continue open_storage: Open file storage diff --git a/modules/storages/config/routes.rb b/modules/storages/config/routes.rb index 5ef6c8b912f..4b8e44a451b 100644 --- a/modules/storages/config/routes.rb +++ b/modules/storages/config/routes.rb @@ -64,6 +64,7 @@ Rails.application.routes.draw do patch :change_health_notifications_enabled get :confirm_destroy delete :replace_oauth_application + post :ampf_sync_now end get :upsell, on: :collection From 25e5c4120824f10fa4a114c67221f1615da5254f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 16 Mar 2026 11:55:42 +0300 Subject: [PATCH 154/435] Reduce allocations: string slicing, single-pass filter, simpler set merge - Replace scan+join with gsub for stripping non-alphanumeric chars - Use string slicing instead of .chars arrays throughout multi-word pipeline, eliminating intermediate array allocations per word - Merge two-pass filter_map+select into single filter_map - Replace index.with_index enumerator with each_index.find - Simplify combined_identifiers from splat+reduce to set union --- ...project_identifier_suggestion_generator.rb | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index aedc202b013..cc19038e56c 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -109,8 +109,10 @@ module WorkPackages return [FALLBACK_IDENTIFIER] if words.empty? candidates = words.size == 1 ? single_word_candidates(words.first) : multi_word_candidates(words) - candidates = candidates.filter_map { ensure_starts_with_letter(it) } - candidates = candidates.select { it.length >= IDENTIFIER_LENGTH[:min] } + candidates = candidates.filter_map do |c| + stripped = ensure_starts_with_letter(c) + stripped if stripped&.length.to_i >= IDENTIFIER_LENGTH[:min] + end candidates.presence || [FALLBACK_IDENTIFIER] end @@ -122,7 +124,7 @@ module WorkPackages raw_words = name.to_s.scan(/[[:alpha:][:digit:]]+/) raw_words.filter_map do |word| t = I18n.with_locale(:en) { I18n.transliterate(word) } - clean = t.scan(/[A-Za-z0-9]/).join + clean = t.gsub(/[^A-Za-z0-9]/, "") clean.presence end end @@ -143,36 +145,36 @@ module WorkPackages # Starts with initials truncated to IDENTIFIER_LENGTH[:base], progressively # includes more initials, then expands words beyond single chars. def multi_word_candidates(words) - clean_words = words.map { |w| w.upcase.chars } - candidates = initial_candidates(clean_words) + upcased_words = words.map(&:upcase) + candidates = initial_candidates(upcased_words) - expand_words_into(candidates, clean_words) if candidates.last.length < IDENTIFIER_LENGTH[:max] + append_expansion_candidates!(candidates, upcased_words) if candidates.last.length < IDENTIFIER_LENGTH[:max] candidates end - def initial_candidates(clean_words) - initials = clean_words.map(&:first).join[0, IDENTIFIER_LENGTH[:max]] + def initial_candidates(upcased_words) + initials = upcased_words.pluck(0).join[0, IDENTIFIER_LENGTH[:max]] start = [IDENTIFIER_LENGTH[:base], initials.length].min (start..initials.length).map { |len| initials[0, len] } end # Progressively pulls more characters from each word left-to-right. - def expand_words_into(candidates, clean_words) - chars_per_word = clean_words.map { 1 } + def append_expansion_candidates!(candidates, upcased_words) + chars_per_word = upcased_words.map { 1 } loop do - expandable = clean_words.index.with_index { |cw, i| chars_per_word[i] < cw.length } + expandable = upcased_words.each_index.find { |i| chars_per_word[i] < upcased_words[i].length } break unless expandable chars_per_word[expandable] += 1 - candidate = build_candidate(clean_words, chars_per_word) + candidate = build_candidate(upcased_words, chars_per_word) candidates << candidate unless candidates.include?(candidate) break if candidate.length >= IDENTIFIER_LENGTH[:max] end end - def build_candidate(clean_words, chars_per_word) - parts = clean_words.each_with_index.map { |cw, i| cw.first(chars_per_word[i]).join } + def build_candidate(upcased_words, chars_per_word) + parts = upcased_words.each_with_index.map { |w, i| w[0, chars_per_word[i]] } parts.join[0, IDENTIFIER_LENGTH[:max]] end @@ -212,8 +214,8 @@ module WorkPackages end end - def combined_identifiers(*sets) - sets.reduce(Set.new, :merge) + def combined_identifiers(set_a, set_b) + set_a | set_b end end end From f8d1663827c0d4e47e9aa292f83fa244545ba6ec Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 16 Mar 2026 11:58:20 +0300 Subject: [PATCH 155/435] Use it syntax and method chaining for single-expression blocks --- .../project_identifier_suggestion_generator.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index cc19038e56c..141c3583bdb 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -136,7 +136,7 @@ module WorkPackages return [] if max_len < IDENTIFIER_LENGTH[:min] start_len = IDENTIFIER_LENGTH[:single_word].clamp(IDENTIFIER_LENGTH[:min], max_len) - (start_len..max_len).map { |len| chars[0, len] } + (start_len..max_len).map { chars[0, it] } end # "Stream Communicator" → ["SC", "STC", "STCO", "STRCO", …] @@ -155,7 +155,7 @@ module WorkPackages def initial_candidates(upcased_words) initials = upcased_words.pluck(0).join[0, IDENTIFIER_LENGTH[:max]] start = [IDENTIFIER_LENGTH[:base], initials.length].min - (start..initials.length).map { |len| initials[0, len] } + (start..initials.length).map { initials[0, it] } end # Progressively pulls more characters from each word left-to-right. @@ -182,8 +182,7 @@ module WorkPackages # For names like "3D Printing Lab", initials "3PL" become "PL". # This is lossy but acceptable for auto-generated suggestions. def ensure_starts_with_letter(candidate) - stripped = candidate.sub(/\A\d+/, "") - stripped.presence + candidate.sub(/\A\d+/, "").presence end # Iterates through expansion candidates, then falls back to numeric suffix. From d4a8ca19cc59cead7fcde72580278677c16c04a7 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 16 Mar 2026 11:59:33 +0300 Subject: [PATCH 156/435] Inline set union, remove combined_identifiers wrapper --- .../project_identifier_suggestion_generator.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb index 141c3583bdb..25e0381b5fa 100644 --- a/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb +++ b/app/services/work_packages/identifier_autofix/project_identifier_suggestion_generator.rb @@ -79,7 +79,7 @@ module WorkPackages end def suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new) - used = combined_identifiers(reserved_identifiers, in_use_identifiers) + used = reserved_identifiers | in_use_identifiers candidates = identifier_candidates(name) find_unique(candidates, used) end @@ -87,7 +87,7 @@ module WorkPackages private def generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:) - used_identifiers = combined_identifiers(reserved_identifiers, in_use_identifiers) + used_identifiers = reserved_identifiers | in_use_identifiers projects.map do |project| candidates = identifier_candidates(project.name) @@ -212,10 +212,6 @@ module WorkPackages counter += 1 end end - - def combined_identifiers(set_a, set_b) - set_a | set_b - end end end end From e07a7a3ca7a086daabd4c2f9959322ab707c7a57 Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad Date: Mon, 16 Mar 2026 11:08:32 +0100 Subject: [PATCH 157/435] Set a height for the whole container --- .../non_working_times/calendar_component.sass | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index 459fd9d95cc..42424344f1a 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -1,12 +1,19 @@ +.users-non-working-times-year-overview + // 100% - page header height - subheader height + height: calc(100% - 180px) + @media (max-width: $breakpoint-sm) + height: 100% + +.users-non-working-times-calendar-view + height: 100% + + .op-fc-wrapper + flex-grow: 1 .users-non-working-times-calendar-view .op-fc-wrapper // Rounded corners for the entire calendar - // Remove the inner scroll .fc-multiMonthYear-view - overflow: unset !important border-radius: var(--borderRadius-medium) !important - inset: unset !important - position: relative // Global non-working days rendered as FullCalendar background events .fc-bg-event.non-working-day--global From a6f54aa86bcfa15f509191105590da3f0daf7258 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 16 Mar 2026 12:01:04 +0100 Subject: [PATCH 158/435] Start adapting features tests to new inplace edit functionality --- .../common/inplace_edit_field_component.rb | 2 +- .../display_field_component.html.erb | 4 +++- .../display_fields/display_field_component.rb | 2 +- .../display_fields/select_list_component.rb | 2 +- .../project_custom_fields/show_component.html.erb | 4 +++- .../section_component_spec.rb | 2 +- spec/features/projects/copy_spec.rb | 2 +- .../overview_page/dialog/permission_spec.rb | 14 +++++++------- spec/support/pages/projects/show.rb | 4 ++-- 9 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index d76c6a60683..3bc5248435c 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -148,7 +148,7 @@ module OpenProject { dialog_controller_name: "inplace-edit", dialog_url: dialog_edit_url, - dialog_test_selector: "inplace-edit-dialog-button-#{wrapper_key}" + dialog_test_selector: "inplace-edit-dialog-button-#{model.id}" } end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb index 39335eb3c65..3f452c3bdad 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb @@ -1,7 +1,9 @@ <%= flex_layout( align_items: :flex_start, - justify_content: :space_between + justify_content: :space_between, + test_selector: @system_arguments[:test_selector], + classes: @system_arguments[:display_classes] ) do |flex| flex.with_row(mb: 1) do render OpenProject::Common::AttributeLabelComponent.new( diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index e3756211b4a..3eb7fba175d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -51,7 +51,7 @@ module OpenProject if value.is_a?(TrueClass) || value.is_a?(FalseClass) boolean_display_value(value) - elsif value.present? + elsif value.present? && value != [nil] value.to_s else t("placeholders.default") diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb index 0cb0d112b77..a1aa1d34ad0 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -42,7 +42,7 @@ module OpenProject if value.present? && value != [nil] if custom_field? - formatted_custom_field_values + formatted_custom_field_values.presence || t("placeholders.default") else value.is_a?(Array) ? value.map(&:to_s).join(", ") : value.to_s end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb index 7e30c0381fd..63fa0281813 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb +++ b/modules/overviews/app/components/overviews/project_custom_fields/show_component.html.erb @@ -2,5 +2,7 @@ model: @project, attribute: @project_custom_field.attribute_name.to_sym, open_in_dialog: limited_space?, - truncated: limited_space? + truncated: limited_space?, + test_selector: "project-custom-field-#{@project_custom_field.id}", + display_classes: "op-project-custom-field-container" ) %> diff --git a/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb b/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb index 6e6e180d4e4..bc83efe1729 100644 --- a/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb +++ b/modules/overviews/spec/components/overviews/project_custom_fields/section_component_spec.rb @@ -53,6 +53,6 @@ RSpec.describe Overviews::ProjectCustomFields::SectionComponent, type: :componen end it "renders two custom fields" do - expect(rendered_component).to have_css ".op-inplace-edit--display-field", count: 2 + expect(rendered_component).to have_css ".op-project-custom-field-container", count: 2 end end diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index e2c5b453c18..17277997b18 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -470,7 +470,7 @@ RSpec.describe "Projects copy", :js, overview_page.within_project_attributes_sidebar do # User has no permission to edit project attributes. - expect(page).to have_no_css("[data-test-selector*='project-custom-field-modal-button-']") + expect(page).to have_no_css("[data-test-selector*='inplace-edit-dialog-button-']") # The custom fields are still copied from the parent project. expect(page).to have_content(project_custom_field.name) expect(page).to have_content("some text cf") 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 a0f9d73ea3d..37685063d10 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 @@ -77,7 +77,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "does not show the modal buttons" do overview_page.within_project_attributes_sidebar do - expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']") + expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']") end end end @@ -91,7 +91,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "does not show the modal buttons" do overview_page.within_project_attributes_sidebar do - expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']") + expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']") end end @@ -100,7 +100,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "does not show the modal buttons" do overview_page.within_project_attributes_sidebar do - expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']") + expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']") end end end @@ -117,7 +117,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "does not show the modal buttons" do overview_page.within_project_attributes_sidebar do - expect(page).to have_no_css("[data-test-selector*='project-custom-field-modal-button-']") + expect(page).to have_no_css("[data-test-selector*='inplace-edit-dialog-button-']") end end @@ -126,7 +126,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "shows the modal buttons on all enabled custom fields" do overview_page.within_project_attributes_sidebar do - expect(page).to have_css("[data-test-selector*='project-custom-field-modal-button-']", count: 15) + expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 15) end end end @@ -140,7 +140,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "shows the modal buttons" do overview_page.within_project_attributes_sidebar do - expect(page).to have_css("[data-test-selector*='project-custom-field-modal-button-']", count: 13) + expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 13) end end @@ -149,7 +149,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "shows the modal buttons on all enabled custom fields" do overview_page.within_project_attributes_sidebar do - expect(page).to have_css("[data-test-selector*='project-custom-field-modal-button-']", count: 15) + expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 15) end end end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index be6ef19938f..4766c7cbcda 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -87,7 +87,7 @@ module Pages def expect_custom_field_without_modal_button(custom_field) within_custom_field_container(custom_field) do - expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']") + expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']") end end @@ -99,7 +99,7 @@ module Pages # Once we create the project custom field inline editing, this can be reverted to a normal # capybara click method call. page.execute_script( - "document.querySelector('[data-test-selector=\"project-custom-field-modal-button-#{custom_field.id}\"]').click()" + "document.querySelector('[data-test-selector=\"inplace-edit-dialog-button-#{custom_field.id}\"]').click()" ) end From 213bac83286c73a1b804fb37bbce1102cb8837eb Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 16 Mar 2026 12:03:53 +0100 Subject: [PATCH 159/435] Delay calendar initialization so we don't see a flicker --- .../dynamic/users/non-working-times.controller.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index 695618b15ce..414fb4b103b 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -71,14 +71,11 @@ export default class NonWorkingTimesController extends Controller { private calendar:Calendar; connect() { - this.initializeCalendar(); - - // The stimulus controller gets initialized before the content wrapper is fully shown - // so its height might not be set correctly yet. + // Delay initialization to ensure the calendar container is fully rendered setTimeout(() => { - this.calendar.updateSize(); + this.initializeCalendar(); this.scrollToToday(); - }, 25); + }, 5); } disconnect() { From 9be22f95c4ff8ab5af80f4e93884877ac77514ec Mon Sep 17 00:00:00 2001 From: Johlan Pretorius Date: Mon, 16 Mar 2026 16:18:30 +0200 Subject: [PATCH 160/435] Fix: Add explicit actor field to webhook payloads Work package webhooks now include a top-level `actor` field identifying the user who triggered the event (the journal author), separate from the work package's `author` (the original creator). { "action": "work_package:updated", "actor": { "id": 5, "name": "Jane Smith", "_type": "User", ... }, "work_package": { ... } } The representer continues to use User.system for payload generation, preserving custom field visibility. The actor is sourced from the journal passed to WorkPackageWebhookJob. Resolves: https://community.openproject.org/wp/69658 Co-Authored-By: Claude Opus 4.6 --- .../app/workers/represented_webhook_job.rb | 12 +- .../app/workers/work_package_webhook_job.rb | 17 +++ .../workers/work_package_webhook_job_spec.rb | 112 +++++++++++++++++- 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/modules/webhooks/app/workers/represented_webhook_job.rb b/modules/webhooks/app/workers/represented_webhook_job.rb index a89fcf0feb8..e56e6aec12c 100644 --- a/modules/webhooks/app/workers/represented_webhook_job.rb +++ b/modules/webhooks/app/workers/represented_webhook_job.rb @@ -87,10 +87,14 @@ class RepresentedWebhookJob < WebhookJob # to_json needs to be called within the system user block in order to # have all the custom field visibility permissions set up correctly. User.system.run_given do - { - action: event_name, - payload_key => represented_payload - }.to_json + payload = { action: event_name, payload_key => represented_payload } + actor = actor_payload + payload[:actor] = actor if actor + payload.to_json end end + + def actor_payload + nil + end end diff --git a/modules/webhooks/app/workers/work_package_webhook_job.rb b/modules/webhooks/app/workers/work_package_webhook_job.rb index 4f9cb3928f1..92312622479 100644 --- a/modules/webhooks/app/workers/work_package_webhook_job.rb +++ b/modules/webhooks/app/workers/work_package_webhook_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,6 +29,14 @@ #++ class WorkPackageWebhookJob < RepresentedWebhookJob + attr_reader :journal + + def perform(webhook_id, journal, event_name) + @journal = journal + @resource = journal.journable + super(webhook_id, @resource, event_name) + end + def payload_key :work_package end @@ -34,4 +44,11 @@ class WorkPackageWebhookJob < RepresentedWebhookJob def payload_representer_class ::API::V3::WorkPackages::WorkPackageRepresenter end + + def actor_payload + user = User.find_by(id: journal.user_id) + return nil unless user + + ::API::V3::Users::UserRepresenter.create(user, current_user: User.current) + end end diff --git a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb index 22be3a1e0e6..51179708a80 100644 --- a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb @@ -36,10 +36,11 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do shared_let(:request_url) { "http://example.net/test/42" } shared_let(:work_package) { create(:work_package, subject: title) } shared_let(:webhook) { create(:webhook, all_projects: true, url: request_url, secret: nil) } + shared_let(:journal) { work_package.journals.first } shared_examples "a work package webhook call" do let(:event) { "work_package:created" } - let(:job) { described_class.perform_now webhook.id, work_package, event } + let(:job) { described_class.perform_now webhook.id, journal, event } let(:stubbed_url) { request_url } @@ -166,4 +167,113 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do end end end + + describe "actor field on updated event" do + let(:author) { create(:user, firstname: "Original", lastname: "Author") } + let(:updater) { create(:user, firstname: "Update", lastname: "User") } + let(:work_package) { create(:work_package, author:, subject: title) } + let(:journal) do + work_package.add_journal(user: updater, notes: "Updated the work package") + work_package.save! + work_package.journals.last + end + + it_behaves_like "a work package webhook call" do + let(:event) { "work_package:updated" } + + it "includes actor matching the journal user, not the work package author" do + subject + expect(stub).to have_been_requested + + log = Webhooks::Log.last + payload = JSON.parse(log.request_body) + + expect(payload["actor"]["id"]).to eq updater.id + expect(payload["actor"]["name"]).to eq updater.name + expect(payload["actor"]["_type"]).to eq "User" + expect(payload["actor"]["_links"]["self"]["href"]).to eq "/api/v3/users/#{updater.id}" + + # Author in the work package payload is still the original creator + author_href = payload["work_package"]["_links"]["author"]["href"] + expect(author_href).to include("/api/v3/users/#{author.id}") + end + end + end + + describe "actor field on created event" do + let(:creator) { create(:user, firstname: "Creator", lastname: "Person") } + let(:work_package) { User.execute_as(creator) { create(:work_package, author: creator, subject: title) } } + let(:journal) { work_package.journals.first } + + it_behaves_like "a work package webhook call" do + let(:event) { "work_package:created" } + + it "includes actor matching the creator" do + subject + expect(stub).to have_been_requested + + log = Webhooks::Log.last + payload = JSON.parse(log.request_body) + + expect(payload["actor"]["id"]).to eq creator.id + expect(payload["actor"]["name"]).to eq creator.name + end + end + end + + describe "actor absent when journal user has been deleted" do + let(:updater) { create(:user) } + let(:journal) do + work_package.add_journal(user: updater, notes: "Updated") + work_package.save! + work_package.journals.last + end + + before { updater.destroy } + + it_behaves_like "a work package webhook call" do + let(:event) { "work_package:updated" } + + it "fires the webhook without an actor key" do + expect { subject }.not_to raise_error + expect(stub).to have_been_requested + + log = Webhooks::Log.last + payload = JSON.parse(log.request_body) + expect(payload).not_to have_key("actor") + end + end + end + + describe "admin custom field regression with journal (PR #16912)" do + shared_let(:project) { work_package.project } + shared_let(:custom_field) do + create(:project_custom_field, :string, admin_only: true, projects: [project]) + end + shared_let(:custom_value) do + create(:custom_value, + custom_field:, + customized: project, + value: "wat") + end + let(:updater) { create(:admin) } + let(:journal) do + work_package.add_journal(user: updater, notes: "Updated") + work_package.save! + work_package.journals.last + end + + it_behaves_like "a work package webhook call" do + let(:event) { "work_package:updated" } + + it "includes the custom field value" do + subject + expect(stub).to have_been_requested + + log = Webhooks::Log.last + embedded_project = JSON.parse(log.request_body)["work_package"]["_embedded"]["project"] + expect(embedded_project[custom_field.attribute_name(:camel_case)]).to eq "wat" + end + end + end end From 31a2536f51ed4becc35b606afd9137aad5b4f13f Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 9 Mar 2026 15:12:52 +0100 Subject: [PATCH 161/435] Implement has_principal_details concern and use it for the group --- app/models/concerns/has_principal_details.rb | 92 ++++++++++++++ app/models/group.rb | 40 ++++--- app/models/group_detail.rb | 37 ++++++ app/models/principal.rb | 1 + config/locales/en.yml | 5 + .../20260309130829_add_group_details.rb | 52 ++++++++ .../concerns/has_principal_details_spec.rb | 113 ++++++++++++++++++ spec/models/group_detail_spec.rb | 73 +++++++++++ 8 files changed, 396 insertions(+), 17 deletions(-) create mode 100644 app/models/concerns/has_principal_details.rb create mode 100644 app/models/group_detail.rb create mode 100644 db/migrate/20260309130829_add_group_details.rb create mode 100644 spec/models/concerns/has_principal_details_spec.rb create mode 100644 spec/models/group_detail_spec.rb diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb new file mode 100644 index 00000000000..43bb2df1a04 --- /dev/null +++ b/app/models/concerns/has_principal_details.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module HasPrincipalDetails + extend ActiveSupport::Concern + + class_methods do + # Declares a detail table for this principal subclass. + # + # @param class_name [String] the detail model class (default: "#{ModelName}Detail") + # @param delegated_attributes [Hash] attribute => delegate options + # + # Example: + # has_principal_details "GroupDetail", + # organizational_unit: { allow_nil: true }, + # parent: { allow_nil: true } + # + def has_principal_details(class_name = nil, **delegated_attributes) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Naming/PredicatePrefix + class_name ||= "#{name}Detail" + association_name = class_name.underscore.to_sym + + has_one association_name, foreign_key: :principal_id, + dependent: :destroy, + inverse_of: model_name.element.to_sym, + class_name: class_name.to_s, + autosave: true + accepts_nested_attributes_for association_name + + # Validate the detail record and promote its errors onto the principal + # so they appear as direct attributes (e.g. group.errors[:parent_id]). + validate do + next if detail.nil? || detail.valid? + + detail.errors.each do |error| + errors.add(error.attribute, error.type, message: error.message) + end + end + + # Auto-build the detail record so it's never nil + after_initialize do + build_detail if new_record? && detail.nil? + end + + # Convenience aliases so every subclass can use `detail` + alias_method :detail, association_name + alias_method :detail=, :"#{association_name}=" + alias_method :build_detail, :"build_#{association_name}" + + detail_class = class_name.constantize + + delegated_attributes.each do |attr, options| + opts = options.is_a?(Hash) ? options : {} + delegate attr, to: association_name, **opts + # Also delegate the writer if it exists (skip for query methods ending in ?) + delegate "#{attr}=", to: association_name, **opts unless attr.to_s.end_with?("?") + + # For belongs_to associations, also delegate the _id getter and setter + if detail_class.reflect_on_association(attr)&.macro == :belongs_to + delegate "#{attr}_id", to: association_name, **opts + delegate "#{attr}_id=", to: association_name, **opts + end + end + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 3a17eabc4d3..f51ff076e77 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,6 +31,10 @@ class Group < Principal include ::Scopes::Scoped + has_principal_details "GroupDetail", + organizational_unit: { allow_nil: true }, + parent: { allow_nil: true } + # Register a partial to be rendered on the synchronized groups tab of the groups admin page # # @param title[String] I18n key that will be used as a title for the section @@ -103,24 +107,26 @@ class Group < Principal externalId: :scim_external_id, displayName: :name, members: [ - list: :scim_members, - using: { - value: :id - }, - find_with: ->(scim_list_entry) { - id = scim_list_entry["value"] - type = scim_list_entry["type"] || "User" # Some online examples omit 'type' and believe 'User' will be assumed + { + list: :scim_members, + using: { + value: :id + }, + find_with: ->(scim_list_entry) { + id = scim_list_entry["value"] + type = scim_list_entry["type"] || "User" # Some online examples omit 'type' and believe 'User' will be assumed - case type.downcase - when "user" - User.not_builtin.find_by(id:) - when "group" - # OP does not support nesting of groups but SCIM does. - # For now raises exception in case of group as a member arrival. - raise Scimitar::InvalidSyntaxError.new("Unsupported type #{type.inspect}") - else - raise Scimitar::InvalidSyntaxError.new("Unrecognised type #{type.inspect}") - end + case type.downcase + when "user" + User.not_builtin.find_by(id:) + when "group" + # OP does not support nesting of groups but SCIM does. + # For now raises exception in case of group as a member arrival. + raise Scimitar::InvalidSyntaxError.new("Unsupported type #{type.inspect}") + else + raise Scimitar::InvalidSyntaxError.new("Unrecognised type #{type.inspect}") + end + } } ] } diff --git a/app/models/group_detail.rb b/app/models/group_detail.rb new file mode 100644 index 00000000000..95f7fc92db7 --- /dev/null +++ b/app/models/group_detail.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class GroupDetail < ApplicationRecord + belongs_to :group, inverse_of: :group_detail, foreign_key: :principal_id + belongs_to :parent, class_name: "Group", optional: true + + validates :group, presence: true, uniqueness: true + validates :parent, presence: true, if: -> { parent_id.present? } +end diff --git a/app/models/principal.rb b/app/models/principal.rb index 241286fc41c..56dd85edac8 100644 --- a/app/models/principal.rb +++ b/app/models/principal.rb @@ -30,6 +30,7 @@ class Principal < ApplicationRecord include ::Scopes::Scoped + include HasPrincipalDetails default_scope -> { where.not(status: Principal.statuses[:deleted]) } diff --git a/config/locales/en.yml b/config/locales/en.yml index 9be0da78640..2b750ab68a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1739,6 +1739,11 @@ en: consented_at: "Consented at" group: identity_url: "Identity URL" + parent: "Parent group" + organizational_unit: "Organizational unit" + group_detail: + parent: "Parent group" + organizational_unit: "Organizational unit" user_preference: header_look_and_feel: "Look and feel" header_alerts: "Alerts" diff --git a/db/migrate/20260309130829_add_group_details.rb b/db/migrate/20260309130829_add_group_details.rb new file mode 100644 index 00000000000..e81e2f5cb36 --- /dev/null +++ b/db/migrate/20260309130829_add_group_details.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class AddGroupDetails < ActiveRecord::Migration[8.1] + def change + create_table :group_details do |t| + t.references :principal, null: false, foreign_key: { to_table: :users }, index: { unique: true } + t.boolean :organizational_unit, default: false, null: false + t.references :parent, foreign_key: { to_table: :users } + + t.timestamps + end + + reversible do |dir| + dir.up do + execute <<~SQL.squish + INSERT INTO group_details (principal_id, organizational_unit, created_at, updated_at) + SELECT id, false, NOW(), NOW() + FROM users + WHERE type = 'Group' + SQL + end + end + end +end diff --git a/spec/models/concerns/has_principal_details_spec.rb b/spec/models/concerns/has_principal_details_spec.rb new file mode 100644 index 00000000000..304c5bcf0f1 --- /dev/null +++ b/spec/models/concerns/has_principal_details_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe HasPrincipalDetails do + # Test through Group, which is the real consumer of this concern + let(:group) { create(:group) } + + describe "detail association" do + it "auto-builds a detail record for new instances" do + new_group = Group.new(lastname: "Test") + expect(new_group.detail).to be_present + expect(new_group.detail).to be_a(GroupDetail) + expect(new_group.detail).to be_new_record + end + + it "does not overwrite an existing detail on persisted records" do + expect(group.detail).to be_persisted + detail_id = group.detail.id + + reloaded = Group.find(group.id) + expect(reloaded.detail.id).to eq(detail_id) + end + + it "destroys the detail when the principal is destroyed" do + detail_id = group.detail.id + group.destroy! + + expect(GroupDetail.find_by(id: detail_id)).to be_nil + end + + it "aliases the concrete association to #detail" do + expect(group.detail).to eq(group.group_detail) + end + end + + describe "attribute delegation" do + it "delegates simple attribute readers" do + group.detail.organizational_unit = true + expect(group.organizational_unit).to be true + end + + it "delegates simple attribute writers" do + group.organizational_unit = true + expect(group.detail.organizational_unit).to be true + end + + describe "belongs_to association delegation" do + let(:parent_group) { create(:group) } + + it "delegates the association reader" do + group.detail.parent = parent_group + expect(group.parent).to eq(parent_group) + end + + it "delegates the association writer" do + group.parent = parent_group + expect(group.detail.parent).to eq(parent_group) + end + + it "delegates the _id reader" do + group.detail.parent_id = parent_group.id + expect(group.parent_id).to eq(parent_group.id) + end + + it "delegates the _id writer" do + group.parent_id = parent_group.id + expect(group.detail.parent_id).to eq(parent_group.id) + end + end + end + + describe "error promotion" do + it "promotes detail validation errors onto the principal" do + group.parent_id = 0 + + expect(group).not_to be_valid + expect(group.errors[:parent]).to be_present + end + + it "is valid when the detail is valid" do + expect(group).to be_valid + end + end +end diff --git a/spec/models/group_detail_spec.rb b/spec/models/group_detail_spec.rb new file mode 100644 index 00000000000..de099fcd8c4 --- /dev/null +++ b/spec/models/group_detail_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe GroupDetail do + let(:group) { create(:group) } + + subject { group.detail } + + describe "associations" do + it { is_expected.to belong_to(:group).with_foreign_key(:principal_id) } + it { is_expected.to belong_to(:parent).class_name("Group").optional } + end + + describe "validations" do + it { is_expected.to validate_presence_of(:group) } + it { is_expected.to validate_uniqueness_of(:group) } + + describe "parent validation" do + context "when parent_id is nil" do + it "is valid" do + subject.parent_id = nil + expect(subject).to be_valid + end + end + + context "when parent_id references an existing group" do + let(:parent_group) { create(:group) } + + it "is valid" do + subject.parent_id = parent_group.id + expect(subject).to be_valid + end + end + + context "when parent_id references a non-existent record" do + it "is invalid" do + subject.parent_id = 0 + expect(subject).not_to be_valid + expect(subject.errors[:parent]).to be_present + end + end + end + end +end From e12a11c55939356f9dbe5838601d86ca7a390f54 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 9 Mar 2026 15:26:06 +0100 Subject: [PATCH 162/435] Skip using the *Detail class and implicitly define it --- app/models/concerns/has_principal_details.rb | 81 ++++++++++++++----- app/models/group.rb | 8 +- app/models/group_detail.rb | 37 --------- .../concerns/has_principal_details_spec.rb | 36 ++++++++- spec/models/group_detail_spec.rb | 73 ----------------- 5 files changed, 96 insertions(+), 139 deletions(-) delete mode 100644 app/models/group_detail.rb delete mode 100644 spec/models/group_detail_spec.rb diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index 43bb2df1a04..0eec6caa16b 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -31,30 +31,65 @@ module HasPrincipalDetails extend ActiveSupport::Concern + # Columns on the detail table that are managed automatically + # and should not be delegated to the principal. + DETAIL_INTERNAL_COLUMNS = %w[id principal_id created_at updated_at].freeze + class_methods do # Declares a detail table for this principal subclass. + # The detail model class is generated automatically — no separate file needed. # - # @param class_name [String] the detail model class (default: "#{ModelName}Detail") - # @param delegated_attributes [Hash] attribute => delegate options + # The block is evaluated in the context of the generated detail class, + # so you can declare associations, validations, callbacks, etc. + # + # The back-reference belongs_to, uniqueness constraint, and attribute + # delegation are set up automatically. # # Example: - # has_principal_details "GroupDetail", - # organizational_unit: { allow_nil: true }, - # parent: { allow_nil: true } + # has_principal_details do + # belongs_to :parent, class_name: "Group", optional: true + # validates :parent, presence: true, if: -> { parent_id.present? } + # end # - def has_principal_details(class_name = nil, **delegated_attributes) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Naming/PredicatePrefix - class_name ||= "#{name}Detail" - association_name = class_name.underscore.to_sym + def has_principal_details(&) # rubocop:disable Naming/PredicatePrefix + detail_class = build_detail_class(&) + association_name = detail_class.name.underscore.to_sym + setup_detail_association(association_name, detail_class) + setup_detail_aliases(association_name) + setup_detail_delegation(association_name, detail_class) + end + + private + + def build_detail_class(&block) + self + owner_name = model_name.element.to_sym # e.g. :group + + klass = Class.new(ApplicationRecord) do + belongs_to owner_name, + inverse_of: :"#{owner_name}_detail", + foreign_key: :principal_id + + validates owner_name, presence: true, uniqueness: true + + class_eval(&block) if block + end + + # Register as a named constant so it appears in stack traces, queries, etc. + Object.const_set("#{name}Detail", klass) + end + + def setup_detail_association(association_name, detail_class) # rubocop:disable Metrics/AbcSize has_one association_name, foreign_key: :principal_id, dependent: :destroy, inverse_of: model_name.element.to_sym, - class_name: class_name.to_s, + class_name: detail_class.name, autosave: true accepts_nested_attributes_for association_name # Validate the detail record and promote its errors onto the principal - # so they appear as direct attributes (e.g. group.errors[:parent_id]). + # so they appear as direct attributes (e.g. group.errors[:parent]). validate do next if detail.nil? || detail.valid? @@ -67,25 +102,27 @@ module HasPrincipalDetails after_initialize do build_detail if new_record? && detail.nil? end + end - # Convenience aliases so every subclass can use `detail` + def setup_detail_aliases(association_name) alias_method :detail, association_name alias_method :detail=, :"#{association_name}=" alias_method :build_detail, :"build_#{association_name}" + end - detail_class = class_name.constantize + def setup_detail_delegation(association_name, detail_class) + # Delegate all non-internal columns + detail_columns = detail_class.column_names - DETAIL_INTERNAL_COLUMNS + detail_columns.each do |col| + delegate col.to_sym, :"#{col}=", to: association_name, allow_nil: true + end - delegated_attributes.each do |attr, options| - opts = options.is_a?(Hash) ? options : {} - delegate attr, to: association_name, **opts - # Also delegate the writer if it exists (skip for query methods ending in ?) - delegate "#{attr}=", to: association_name, **opts unless attr.to_s.end_with?("?") + # For belongs_to associations, also delegate the object reader/writer + # (columns like parent_id are already covered above) + detail_class.reflect_on_all_associations(:belongs_to).each do |reflection| + next if reflection.name == model_name.element.to_sym # skip the back-reference - # For belongs_to associations, also delegate the _id getter and setter - if detail_class.reflect_on_association(attr)&.macro == :belongs_to - delegate "#{attr}_id", to: association_name, **opts - delegate "#{attr}_id=", to: association_name, **opts - end + delegate reflection.name, :"#{reflection.name}=", to: association_name, allow_nil: true end end end diff --git a/app/models/group.rb b/app/models/group.rb index f51ff076e77..511e0c5c1ee 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,9 +31,11 @@ class Group < Principal include ::Scopes::Scoped - has_principal_details "GroupDetail", - organizational_unit: { allow_nil: true }, - parent: { allow_nil: true } + has_principal_details do + belongs_to :parent, class_name: "Group", optional: true + + validates :parent, presence: true, if: -> { parent_id.present? } + end # Register a partial to be rendered on the synchronized groups tab of the groups admin page # diff --git a/app/models/group_detail.rb b/app/models/group_detail.rb deleted file mode 100644 index 95f7fc92db7..00000000000 --- a/app/models/group_detail.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -class GroupDetail < ApplicationRecord - belongs_to :group, inverse_of: :group_detail, foreign_key: :principal_id - belongs_to :parent, class_name: "Group", optional: true - - validates :group, presence: true, uniqueness: true - validates :parent, presence: true, if: -> { parent_id.present? } -end diff --git a/spec/models/concerns/has_principal_details_spec.rb b/spec/models/concerns/has_principal_details_spec.rb index 304c5bcf0f1..26afad26e63 100644 --- a/spec/models/concerns/has_principal_details_spec.rb +++ b/spec/models/concerns/has_principal_details_spec.rb @@ -34,6 +34,27 @@ RSpec.describe HasPrincipalDetails do # Test through Group, which is the real consumer of this concern let(:group) { create(:group) } + describe "generated detail class" do + it "creates a named constant for the detail class" do + expect(defined?(GroupDetail)).to eq("constant") + expect(GroupDetail.superclass).to eq(ApplicationRecord) + end + + it "sets up the back-reference belongs_to" do + reflection = GroupDetail.reflect_on_association(:group) + expect(reflection).to be_present + expect(reflection.macro).to eq(:belongs_to) + expect(reflection.foreign_key).to eq("principal_id") + end + + it "evaluates the block on the detail class" do + reflection = GroupDetail.reflect_on_association(:parent) + expect(reflection).to be_present + expect(reflection.macro).to eq(:belongs_to) + expect(reflection.options[:class_name]).to eq("Group") + end + end + describe "detail association" do it "auto-builds a detail record for new instances" do new_group = Group.new(lastname: "Test") @@ -63,12 +84,12 @@ RSpec.describe HasPrincipalDetails do end describe "attribute delegation" do - it "delegates simple attribute readers" do + it "delegates column readers" do group.detail.organizational_unit = true expect(group.organizational_unit).to be true end - it "delegates simple attribute writers" do + it "delegates column writers" do group.organizational_unit = true expect(group.detail.organizational_unit).to be true end @@ -86,16 +107,23 @@ RSpec.describe HasPrincipalDetails do expect(group.detail.parent).to eq(parent_group) end - it "delegates the _id reader" do + it "delegates the _id reader via column delegation" do group.detail.parent_id = parent_group.id expect(group.parent_id).to eq(parent_group.id) end - it "delegates the _id writer" do + it "delegates the _id writer via column delegation" do group.parent_id = parent_group.id expect(group.detail.parent_id).to eq(parent_group.id) end end + + it "does not delegate internal columns to the detail" do + # These methods exist on Group itself (from AR), but should not be + # delegated through to the detail record. + group.detail.update_column(:created_at, 1.day.ago) + expect(group.created_at).not_to eq(group.detail.created_at) + end end describe "error promotion" do diff --git a/spec/models/group_detail_spec.rb b/spec/models/group_detail_spec.rb deleted file mode 100644 index de099fcd8c4..00000000000 --- a/spec/models/group_detail_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" - -RSpec.describe GroupDetail do - let(:group) { create(:group) } - - subject { group.detail } - - describe "associations" do - it { is_expected.to belong_to(:group).with_foreign_key(:principal_id) } - it { is_expected.to belong_to(:parent).class_name("Group").optional } - end - - describe "validations" do - it { is_expected.to validate_presence_of(:group) } - it { is_expected.to validate_uniqueness_of(:group) } - - describe "parent validation" do - context "when parent_id is nil" do - it "is valid" do - subject.parent_id = nil - expect(subject).to be_valid - end - end - - context "when parent_id references an existing group" do - let(:parent_group) { create(:group) } - - it "is valid" do - subject.parent_id = parent_group.id - expect(subject).to be_valid - end - end - - context "when parent_id references a non-existent record" do - it "is invalid" do - subject.parent_id = 0 - expect(subject).not_to be_valid - expect(subject.errors[:parent]).to be_present - end - end - end - end -end From ae71c27c97c4052026ce98943d69418677b3d802 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 9 Mar 2026 16:49:11 +0100 Subject: [PATCH 163/435] Implement hierarchy for groups and prevent circular dependencies --- app/models/concerns/has_principal_details.rb | 1 - app/models/group.rb | 9 ++ app/models/groups/hierarchy.rb | 116 +++++++++++++++++++ config/locales/en.yml | 6 + spec/models/group_spec.rb | 111 ++++++++++++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 app/models/groups/hierarchy.rb diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index 0eec6caa16b..4b9c70ddc9b 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -63,7 +63,6 @@ module HasPrincipalDetails private def build_detail_class(&block) - self owner_name = model_name.element.to_sym # e.g. :group klass = Class.new(ApplicationRecord) do diff --git a/app/models/group.rb b/app/models/group.rb index 511e0c5c1ee..8d678c4d20e 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -30,6 +30,7 @@ class Group < Principal include ::Scopes::Scoped + include Groups::Hierarchy has_principal_details do belongs_to :parent, class_name: "Group", optional: true @@ -37,6 +38,8 @@ class Group < Principal validates :parent, presence: true, if: -> { parent_id.present? } end + validate :no_circular_parent, if: -> { parent_id.present? } + # Register a partial to be rendered on the synchronized groups tab of the groups admin page # # @param title[String] I18n key that will be used as a title for the section @@ -155,4 +158,10 @@ class Group < Principal def fail_add fail "Do not add users through association, use `Groups::AddUsersService` instead." end + + def no_circular_parent + if parent_id == id || descendant_ids.include?(parent_id) + errors.add(:parent_id, :circular_dependency) + end + end end diff --git a/app/models/groups/hierarchy.rb b/app/models/groups/hierarchy.rb new file mode 100644 index 00000000000..aac1be491d0 --- /dev/null +++ b/app/models/groups/hierarchy.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Groups::Hierarchy + extend ActiveSupport::Concern + + # Direct children of this group. + def children + Group.joins(:group_detail).where(group_details: { parent_id: id }) + end + + # All groups below this one in the tree (any depth). + def descendants + Group.where(id: descendant_ids) + end + + # Self and all descendant groups. + def self_and_descendants + Group.where(id: [id] + descendant_ids) + end + + # All groups above this one in the tree up to the root. + def ancestors + Group.where(id: ancestor_ids) + end + + # Self and all ancestor groups, ordered from root down. + def self_and_ancestors + Group.where(id: [id] + ancestor_ids) + end + + # The topmost group in this tree. Returns self if already the root. + # Note: relies on Postgres UNION ALL CTE processing rows in insertion order, + # so ancestor_ids are returned child-first; the last entry is the root. + def root + root_id = ancestor_ids.last + root_id ? Group.find(root_id) : self + end + + # True if this group has no parent. + def root? + parent_id.nil? + end + + private + + def descendant_ids + return [] if new_record? + + sql = self.class.sanitize_sql([<<~SQL.squish, id]) + WITH RECURSIVE group_descendants(id) AS ( + SELECT gd.principal_id + FROM group_details gd + WHERE gd.parent_id = ? + + UNION ALL + + SELECT gd.principal_id + FROM group_details gd + INNER JOIN group_descendants ON gd.parent_id = group_descendants.id + ) + SELECT id FROM group_descendants + SQL + + self.class.connection.select_values(sql, "Group descendants") + end + + def ancestor_ids + return [] if new_record? || parent_id.nil? + + sql = self.class.sanitize_sql([<<~SQL.squish, id]) + WITH RECURSIVE group_ancestors(id) AS ( + SELECT gd.parent_id + FROM group_details gd + WHERE gd.principal_id = ? AND gd.parent_id IS NOT NULL + + UNION ALL + + SELECT gd.parent_id + FROM group_details gd + INNER JOIN group_ancestors ON gd.principal_id = group_ancestors.id + WHERE gd.parent_id IS NOT NULL + ) + SELECT id FROM group_ancestors + SQL + + self.class.connection.select_values(sql, "Group ancestors") + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2b750ab68a1..4316ddd65f4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1476,6 +1476,12 @@ en: dependencies: "Dependencies" activerecord: + errors: + models: + group: + attributes: + parent_id: + circular_dependency: "would create a circular group hierarchy." attributes: jira_import: projects: "Projects" diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index f308925b9e7..7b9e6c1dc13 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -203,6 +203,117 @@ RSpec.describe Group do end end + describe "hierarchy" do + # Build a tree: grandparent -> parent -> child -> grandchild + let!(:grandparent) { create(:group) } + let!(:parent_group) { create(:group, parent_id: grandparent.id) } + let!(:child) { create(:group, parent_id: parent_group.id) } + let!(:grandchild) { create(:group, parent_id: child.id) } + let!(:unrelated) { create(:group) } + + describe "#children" do + it "returns direct children only" do + expect(grandparent.children).to contain_exactly(parent_group) + expect(parent_group.children).to contain_exactly(child) + end + + it "returns empty for a leaf group" do + expect(grandchild.children).to be_empty + end + end + + describe "#descendants" do + it "returns all groups below in the tree" do + expect(grandparent.descendants).to contain_exactly(parent_group, child, grandchild) + end + + it "returns direct child and its subtree" do + expect(parent_group.descendants).to contain_exactly(child, grandchild) + end + + it "returns empty for a leaf group" do + expect(grandchild.descendants).to be_empty + end + + it "does not include unrelated groups" do + expect(grandparent.descendants).not_to include(unrelated) + end + end + + describe "#self_and_descendants" do + it "includes self and all descendants" do + expect(grandparent.self_and_descendants).to contain_exactly(grandparent, parent_group, child, grandchild) + end + end + + describe "#ancestors" do + it "returns all groups above in the tree" do + expect(grandchild.ancestors).to contain_exactly(child, parent_group, grandparent) + end + + it "returns empty for a root group" do + expect(grandparent.ancestors).to be_empty + end + end + + describe "#self_and_ancestors" do + it "includes self and all ancestors" do + expect(grandchild.self_and_ancestors).to contain_exactly(grandchild, child, parent_group, grandparent) + end + end + + describe "#root" do + it "returns the topmost ancestor" do + expect(grandchild.root).to eq(grandparent) + expect(child.root).to eq(grandparent) + end + + it "returns self when already the root" do + expect(grandparent.root).to eq(grandparent) + end + end + + describe "#root?" do + it "is true when there is no parent" do + expect(grandparent).to be_root + end + + it "is false when there is a parent" do + expect(child).not_to be_root + end + end + + describe "circular dependency prevention" do + it "is invalid when assigning self as parent" do + grandparent.parent_id = grandparent.id + expect(grandparent).not_to be_valid + expect(grandparent.errors[:parent_id]).to be_present + end + + it "is invalid when assigning a direct child as parent" do + grandparent.parent_id = parent_group.id + expect(grandparent).not_to be_valid + expect(grandparent.errors[:parent_id]).to be_present + end + + it "is invalid when assigning a distant descendant as parent" do + grandparent.parent_id = grandchild.id + expect(grandparent).not_to be_valid + expect(grandparent.errors[:parent_id]).to be_present + end + + it "is valid when assigning an unrelated group as parent" do + grandchild.parent_id = unrelated.id + expect(grandchild).to be_valid + end + + it "is valid when clearing the parent" do + child.parent_id = nil + expect(child).to be_valid + end + end + end + it_behaves_like "creates an audit trail on destroy" do subject { create(:attachment) } end From ff45ad91e087c56c6ccb46867fb72644f3620b51 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 10 Mar 2026 10:53:04 +0100 Subject: [PATCH 164/435] Fix early delegation --- app/models/concerns/has_principal_details.rb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index 4b9c70ddc9b..a6305576fed 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -110,9 +110,20 @@ module HasPrincipalDetails end def setup_detail_delegation(association_name, detail_class) + # Defer schema introspection until first instantiation so that tasks like + # db:create can load the model without a database connection being available. + after_initialize do + self.class.send(:finalize_detail_delegation!, association_name, detail_class) + end + end + + def finalize_detail_delegation!(association_name, detail_class) + return if @_detail_delegation_set_up + + @_detail_delegation_set_up = true + # Delegate all non-internal columns - detail_columns = detail_class.column_names - DETAIL_INTERNAL_COLUMNS - detail_columns.each do |col| + (detail_class.column_names - DETAIL_INTERNAL_COLUMNS).each do |col| delegate col.to_sym, :"#{col}=", to: association_name, allow_nil: true end From ecdea95f4b37e1e96df621aad0e8b6c61797e0c1 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 10 Mar 2026 11:43:09 +0100 Subject: [PATCH 165/435] Scope to eager load details --- app/models/concerns/has_principal_details.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index a6305576fed..ddf978a65bc 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -87,6 +87,8 @@ module HasPrincipalDetails autosave: true accepts_nested_attributes_for association_name + scope :with_detail, -> { joins(association_name).includes(association_name) } + # Validate the detail record and promote its errors onto the principal # so they appear as direct attributes (e.g. group.errors[:parent]). validate do From 9728990fdfed363ffb4256ee679e3a9621be501e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 10 Mar 2026 17:26:23 +0100 Subject: [PATCH 166/435] Implement membership propagation for memberships --- app/services/groups/add_users_service.rb | 17 +- app/services/groups/update_roles_service.rb | 17 +- app/services/groups/update_service.rb | 78 ++- app/services/members/create_service.rb | 3 +- app/services/members/update_service.rb | 4 +- .../hierarchy_membership_integration_spec.rb | 556 ++++++++++++++++++ spec/services/members/create_service_spec.rb | 3 +- spec/services/members/update_service_spec.rb | 80 ++- 8 files changed, 728 insertions(+), 30 deletions(-) create mode 100644 spec/services/groups/hierarchy_membership_integration_spec.rb diff --git a/app/services/groups/add_users_service.rb b/app/services/groups/add_users_service.rb index 1bf8c0f0b89..3740295df17 100644 --- a/app/services/groups/add_users_service.rb +++ b/app/services/groups/add_users_service.rb @@ -52,16 +52,21 @@ module Groups end def after_perform(call) - Groups::CreateInheritedRolesService - .new(model, current_user: user, contract_class:) - .call( - user_ids: params[:ids], - message: params[:message] - ) + create_inherited_roles(model) + + model.ancestors.each do |ancestor| + create_inherited_roles(ancestor) + end call end + def create_inherited_roles(group) + Groups::CreateInheritedRolesService + .new(group, current_user: user, contract_class:) + .call(user_ids: params[:ids], message: params[:message]) + end + def add_to_group <<~SQL.squish INSERT INTO group_users (group_id, user_id) diff --git a/app/services/groups/update_roles_service.rb b/app/services/groups/update_roles_service.rb index 89284efacaa..4297c4faea5 100644 --- a/app/services/groups/update_roles_service.rb +++ b/app/services/groups/update_roles_service.rb @@ -45,13 +45,14 @@ module Groups def modify_members_and_roles(params) member = params.fetch(:member) + user_ids = params.fetch(:user_ids) { model.self_and_descendants.flat_map(&:user_ids).uniq } sql_query = ::OpenProject::SqlSanitization - .sanitize update_roles_cte, - group_id: model.id, + .sanitize(update_roles_cte, member_id: member.id, project_id: member.project_id, - role_ids: member.role_ids + role_ids: member.role_ids, + user_ids:) execute_query(sql_query) end @@ -59,17 +60,11 @@ module Groups def update_roles_cte <<~SQL WITH - -- select all users of the group - group_users AS ( - SELECT user_id - FROM #{GroupUser.table_name} - WHERE group_id = :group_id - ), - -- select all members of the users of the group + -- select all members of the users of the group (direct and descendants) user_members AS ( SELECT id FROM #{Member.table_name} - WHERE user_id IN (SELECT user_id FROM group_users) + WHERE user_id IN (:user_ids) AND project_id IS NOT DISTINCT FROM :project_id ), -- select all member roles the group has for the member diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 3acbec28e3b..f5f62c636a6 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -37,10 +37,13 @@ class Groups::UpdateService < BaseServices::Update project_ids = member_roles.pluck(:project_id) member_role_ids = member_roles.pluck(:id) + former_parent_id = model.detail&.parent_id_in_database + call = super remove_member_roles(member_role_ids) cleanup_members(removed_users, project_ids) + handle_parent_change(former_parent_id) call end @@ -69,13 +72,80 @@ class Groups::UpdateService < BaseServices::Update .call(member_role_ids:) end - def member_roles_to_prune(users) + def member_roles_to_prune(users) # rubocop:disable Metrics/AbcSize return MemberRole.none if users.empty? - MemberRole - .includes(member: :member_roles) + user_ids = users.map(&:id) + + direct_ids = MemberRole + .joins(:member) .where(inherited_from: model.members.joins(:member_roles).select("member_roles.id")) - .where(members: { user_id: users.map(&:id) }) + .where(members: { user_id: user_ids }) + .pluck(:id) + + ancestor_ids = ancestor_member_role_ids_to_prune(users) + + all_ids = (direct_ids + ancestor_ids).uniq + return MemberRole.none if all_ids.empty? + + MemberRole.joins(:member).where(id: all_ids) + end + + def ancestor_member_role_ids_to_prune(users) + model.ancestors.flat_map do |ancestor| + users_not_in_ancestor = users.reject { |u| ancestor.user_ids.include?(u.id) } + next [] if users_not_in_ancestor.empty? + + MemberRole + .joins(:member) + .where(inherited_from: ancestor.members.joins(:member_roles).select("member_roles.id")) + .where(members: { user_id: users_not_in_ancestor.map(&:id) }) + .pluck(:id) + end + end + + def handle_parent_change(former_parent_id) + new_parent_id = model.detail&.parent_id + return if former_parent_id == new_parent_id + + propagate_ancestor_memberships if new_parent_id.present? + cleanup_former_ancestor_memberships(former_parent_id) if former_parent_id.present? + end + + def propagate_ancestor_memberships + user_ids = model.self_and_descendants.flat_map(&:user_ids).uniq + return if user_ids.empty? + + model.ancestors.each do |ancestor| + Groups::CreateInheritedRolesService + .new(ancestor, current_user: user) + .call(user_ids:) + end + end + + def cleanup_former_ancestor_memberships(former_parent_id) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity + former_parent = Group.find_by(id: former_parent_id) + return unless former_parent + + affected_users = model.self_and_descendants.flat_map(&:users).uniq + return if affected_users.empty? + + former_parent.self_and_ancestors.each do |ancestor| + users_not_in_ancestor = affected_users.reject { |u| ancestor.user_ids.include?(u.id) } + next if users_not_in_ancestor.empty? + + role_ids_to_clean = MemberRole + .joins(:member) + .where(inherited_from: ancestor.members.joins(:member_roles).select("member_roles.id")) + .where(members: { user_id: users_not_in_ancestor.map(&:id) }) + .pluck(:id) + + next if role_ids_to_clean.empty? + + Groups::CleanupInheritedRolesService + .new(ancestor, current_user: user) + .call(member_role_ids: role_ids_to_clean) + end end def cleanup_members(users, project_ids) diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 393f5b5bcef..510ac2dac2a 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -50,10 +50,11 @@ class Members::CreateService < BaseServices::Create return unless member.principal.is_a?(Group) project_ids = member.project_id.nil? ? nil : [member.project_id] + user_ids = member.principal.self_and_descendants.flat_map(&:user_ids).uniq Groups::CreateInheritedRolesService .new(member.principal, current_user: user, contract_class: EmptyContract) - .call(user_ids: member.principal.user_ids, send_notifications: false, project_ids:) + .call(user_ids: user_ids, send_notifications: false, project_ids:) end def event_type diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b81ed88f86a..8054e6c4ff5 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -51,9 +51,11 @@ class Members::UpdateService < BaseServices::Update end def update_group_roles(member) + user_ids = member.principal.self_and_descendants.flat_map(&:user_ids).uniq + Groups::UpdateRolesService .new(member.principal, current_user: user, contract_class: EmptyContract) - .call(member:, send_notifications: send_notifications?, message: notification_message) + .call(member:, user_ids:, send_notifications: send_notifications?, message: notification_message) end def event_type diff --git a/spec/services/groups/hierarchy_membership_integration_spec.rb b/spec/services/groups/hierarchy_membership_integration_spec.rb new file mode 100644 index 00000000000..16fbe701dad --- /dev/null +++ b/spec/services/groups/hierarchy_membership_integration_spec.rb @@ -0,0 +1,556 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +# Integration tests for membership propagation through group hierarchies. +# +# The hierarchy under test (unless a specific test sets up its own): +# +# root_group +# └── mid_group +# └── leaf_group +# +# Each group has one exclusive direct member: +# root_user ∈ root_group, mid_user ∈ mid_group, leaf_user ∈ leaf_group +# +RSpec.describe "Group hierarchy membership propagation", type: :model do + let(:admin) { create(:admin) } + let(:project) { create(:project) } + let(:role) { create(:project_role) } + let(:root_user) { create(:user) } + let(:mid_user) { create(:user) } + let(:leaf_user) { create(:user) } + + before do + allow(Notifications::GroupMemberAlteredJob).to receive(:perform_later) + end + + # --------------------------------------------------------------------------- + # Members::CreateService — Making a group a member of a project should + # propagate the membership to all users in the subtree + # --------------------------------------------------------------------------- + + describe "Members::CreateService" do + let!(:root_group) { create(:group, members: [root_user]) } + let!(:mid_group) { create(:group, members: [mid_user], parent: root_group) } + let!(:leaf_group) { create(:group, members: [leaf_user], parent: mid_group) } + + it "propagates the membership to all members in the subtree when given to the root group" do + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + expect(root_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + expect(mid_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + expect(leaf_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "only propagates to the subtree of the group receiving the membership, not to ancestors" do + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [role.id]) + + expect(mid_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + expect(leaf_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + expect(root_user.memberships.find_by(project:)).to be_nil + end + end + + # --------------------------------------------------------------------------- + # Members::UpdateService — changing roles on a group membership updates all subtree members + # --------------------------------------------------------------------------- + + describe "Members::UpdateService" do + let!(:root_group) { create(:group, members: [root_user]) } + let!(:mid_group) { create(:group, members: [mid_user], parent: root_group) } + let!(:leaf_group) { create(:group, members: [leaf_user], parent: mid_group) } + + let(:second_role) { create(:project_role) } + + it "updates the inherited roles for all members in the hierarchy when the root group's membership changes" do + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + group_member = Member.find_by!(principal: root_group, project:) + + Members::UpdateService + .new(user: admin, model: group_member) + .call(role_ids: [role.id, second_role.id]) + + expect(root_user.memberships.find_by(project:).roles).to contain_exactly(role, second_role) + expect(mid_user.memberships.find_by(project:).roles).to contain_exactly(role, second_role) + expect(leaf_user.memberships.find_by(project:).roles).to contain_exactly(role, second_role) + end + end + + # --------------------------------------------------------------------------- + # Groups::AddUsersService — adding a user to a group also inherits ancestor memberships + # --------------------------------------------------------------------------- + + describe "Groups::AddUsersService" do + let!(:new_user) { create(:user) } + + it "gives a newly added leaf-group member the memberships inherited from all ancestor groups" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::AddUsersService + .new(leaf_group, current_user: admin) + .call(ids: [new_user.id], message: nil) + + expect(new_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "gives a newly added mid-group member the memberships inherited from the root group" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, parent: root_group) + create(:group, parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::AddUsersService + .new(mid_group, current_user: admin) + .call(ids: [new_user.id], message: nil) + + expect(new_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "does not inherit ancestor memberships for a user added to the root (no ancestors exist)" do + root_group = create(:group) + mid_group = create(:group, parent: root_group) + + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [role.id]) + + Groups::AddUsersService + .new(root_group, current_user: admin) + .call(ids: [new_user.id], message: nil) + + expect(new_user.memberships.find_by(project:)).to be_nil + end + end + + # --------------------------------------------------------------------------- + # Groups::UpdateService — removing a user cleans up ancestor-inherited memberships + # --------------------------------------------------------------------------- + + describe "Groups::UpdateService — user removal" do + it "removes the ancestor-inherited memberships when a user is removed from a leaf group" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: leaf_group) + .call(remove_user_ids: [leaf_user.id]) + + expect(leaf_user.memberships.find_by(project:)).to be_nil + end + + it "does not affect other group members when a user is removed from a leaf group" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: leaf_group) + .call(remove_user_ids: [leaf_user.id]) + + expect(root_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + expect(mid_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "retains the inherited membership when the removed user still belongs to an ancestor group" do + shared_user = create(:user) + root_group = create(:group, members: [shared_user]) + mid_group = create(:group, parent: root_group) + leaf_group = create(:group, parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + # Also add shared_user to leaf_group directly + Groups::AddUsersService + .new(leaf_group, current_user: admin) + .call(ids: [shared_user.id], message: nil) + + Groups::UpdateService + .new(user: admin, model: leaf_group) + .call(remove_user_ids: [shared_user.id]) + + # shared_user is still in root_group, so the membership must be retained + expect(shared_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "removes ancestor-inherited memberships when a user is removed from an intermediate group" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + # Removing mid_user from mid_group should also clean up the root-inherited membership + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(remove_user_ids: [mid_user.id]) + + expect(mid_user.memberships.find_by(project:)).to be_nil + # leaf_user is unaffected — still inherits from root via the hierarchy + expect(leaf_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + end + + # --------------------------------------------------------------------------- + # Groups::UpdateService — changing the parent propagates/cleans up memberships + # --------------------------------------------------------------------------- + + describe "Groups::UpdateService — parent change" do + it "removes the root-inherited memberships from mid and leaf users when the parent link is broken" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: nil) + + expect(mid_user.memberships.find_by(project:)).to be_nil + expect(leaf_user.memberships.find_by(project:)).to be_nil + end + + it "keeps the root user's membership intact when the parent link of a child is broken" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: nil) + + expect(root_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "does not remove memberships from users who still belong to the former ancestor directly" do + shared_user = create(:user) + root_group = create(:group, members: [shared_user]) + mid_group = create(:group, members: [shared_user], parent: root_group) + create(:group, parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: nil) + + # shared_user is still directly in root_group, so their membership must survive + expect(shared_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "propagates the new parent's memberships to all users in the subtree when a parent is assigned" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user]) + create(:group, members: [leaf_user], parent: mid_group) + # leaf is under mid, but mid has no parent yet + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: root_group.id) + + expect(mid_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + expect(leaf_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + + it "does not assign the new parent's memberships to users in unrelated groups" do + other_user = create(:user) + root_group = create(:group) + mid_group = create(:group, members: [mid_user]) + create(:group, members: [other_user]) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + # Connect mid_group to root_group — other_group is unrelated and must not be affected + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: root_group.id) + + expect(other_user.memberships.find_by(project:)).to be_nil + end + + it "swaps inherited memberships when a group is re-parented from one root to another" do + old_role = create(:project_role) + new_role = create(:project_role) + old_root = create(:group) + new_root = create(:group) + mid_group = create(:group, members: [mid_user], parent: old_root) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: old_root, project_id: project.id, role_ids: [old_role.id]) + Members::CreateService + .new(user: admin) + .call(principal: new_root, project_id: project.id, role_ids: [new_role.id]) + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: new_root.id) + + # Both mid and leaf should now have new_role, not old_role + expect(mid_user.memberships.find_by(project:).roles).to contain_exactly(new_role) + expect(leaf_user.memberships.find_by(project:).roles).to contain_exactly(new_role) + end + end + + # --------------------------------------------------------------------------- + # Overlapping memberships at different hierarchy levels + # --------------------------------------------------------------------------- + + describe "overlapping memberships at different levels" do + it "keeps the mid group's own role when the parent link is broken, removing only the inherited root role" do + root_role = create(:project_role) + mid_role = create(:project_role) + root_group = create(:group) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + # Root group gets root_role, mid group gets its own mid_role + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [root_role.id]) + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [mid_role.id]) + + # Before breaking: mid_user has both roles, leaf_user has both roles + expect(mid_user.memberships.find_by(project:).roles).to contain_exactly(root_role, mid_role) + expect(leaf_user.memberships.find_by(project:).roles).to contain_exactly(root_role, mid_role) + + # Break the parent link + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: nil) + + # After breaking: only mid_role remains — root_role was inherited and should be cleaned up + expect(mid_user.memberships.find_by(project:).roles).to contain_exactly(mid_role) + expect(leaf_user.memberships.find_by(project:).roles).to contain_exactly(mid_role) + end + end + + # --------------------------------------------------------------------------- + # Deep hierarchy (more than 3 levels) + # --------------------------------------------------------------------------- + + describe "deep hierarchy" do + it "propagates memberships through a 5-level hierarchy" do + users = create_list(:user, 5) + groups = users.each_with_object([]) do |u, acc| + acc << create(:group, members: [u], parent: acc.last) + end + + Members::CreateService + .new(user: admin) + .call(principal: groups[0], project_id: project.id, role_ids: [role.id]) + + # Every user in the hierarchy should have the role + users.each_with_index do |user, i| + expect(user.memberships.find_by(project:)&.roles) + .to contain_exactly(role), + "expected user in group[#{i}] to have the role" + end + end + + it "cleans up memberships through a 5-level hierarchy when the parent link at level 2 is broken" do + users = create_list(:user, 5) + groups = users.each_with_object([]) do |u, acc| + acc << create(:group, members: [u], parent: acc.last) + end + + Members::CreateService + .new(user: admin) + .call(principal: groups[0], project_id: project.id, role_ids: [role.id]) + + # Break the link between groups[1] and groups[0] + Groups::UpdateService + .new(user: admin, model: groups[1]) + .call(parent_id: nil) + + # groups[0] user keeps the membership + expect(users[0].memberships.find_by(project:)&.roles).to contain_exactly(role) + + # groups[1..4] users lose the inherited membership + (1..4).each do |i| + expect(users[i].memberships.find_by(project:)) + .to be_nil, + "expected user in group[#{i}] to have no membership after breaking the link" + end + end + end + + # --------------------------------------------------------------------------- + # Diamond-shaped membership — user in both root and leaf + # --------------------------------------------------------------------------- + + describe "diamond-shaped membership" do + it "retains membership via root_group when the user is also in leaf_group and the parent link is broken" do + shared_user = create(:user) + root_group = create(:group, members: [shared_user]) + mid_group = create(:group, parent: root_group) + create(:group, members: [shared_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + # Break the hierarchy — leaf_group is no longer under root_group + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: nil) + + # shared_user is still a direct member of root_group, so the membership survives + expect(shared_user.memberships.find_by(project:)&.roles).to contain_exactly(role) + end + end + + # --------------------------------------------------------------------------- + # Members::DeleteService — deleting a group membership cascades to descendants + # --------------------------------------------------------------------------- + + describe "Members::DeleteService" do + it "removes all inherited member_roles from descendant users when the root group's membership is deleted" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + group_member = Member.find_by!(principal: root_group, project:) + + Members::DeleteService + .new(user: admin, model: group_member) + .call + + expect(root_user.memberships.find_by(project:)).to be_nil + expect(mid_user.memberships.find_by(project:)).to be_nil + expect(leaf_user.memberships.find_by(project:)).to be_nil + end + + it "keeps memberships from other groups when only one ancestor's membership is deleted" do + root_role = create(:project_role) + mid_role = create(:project_role) + root_group = create(:group) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [root_role.id]) + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [mid_role.id]) + + # Delete only the root group's membership + root_member = Member.find_by!(principal: root_group, project:) + Members::DeleteService + .new(user: admin, model: root_member) + .call + + # mid_role (from mid_group's own membership) should survive + expect(mid_user.memberships.find_by(project:)&.roles).to contain_exactly(mid_role) + expect(leaf_user.memberships.find_by(project:)&.roles).to contain_exactly(mid_role) + end + end + + # --------------------------------------------------------------------------- + # Adding a user to a group with memberships from multiple ancestors + # --------------------------------------------------------------------------- + + describe "adding a user to a group with multiple ancestor memberships" do + it "gives the new user roles inherited from both the group's own membership and its ancestor's membership" do + root_role = create(:project_role) + mid_role = create(:project_role) + new_user = create(:user) + root_group = create(:group, members: [root_user]) + mid_group = create(:group, parent: root_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [root_role.id]) + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [mid_role.id]) + + Groups::AddUsersService + .new(mid_group, current_user: admin) + .call(ids: [new_user.id], message: nil) + + # new_user should have both: mid_role from mid_group, root_role inherited from root_group + expect(new_user.memberships.find_by(project:)&.roles).to contain_exactly(root_role, mid_role) + end + end +end diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index f7a3c12978a..150dc9543bb 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -37,8 +37,7 @@ RSpec.describe Members::CreateService, type: :model do let(:group) do build_stubbed(:group).tap do |g| allow(g) - .to receive(:user_ids) - .and_return([user1.id, user2.id]) + .to receive_messages(user_ids: [user1.id, user2.id], self_and_descendants: [g]) end end let!(:inherited_roles_service) do diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb index 1031f360e9a..7b3e51937fa 100644 --- a/spec/services/members/update_service_spec.rb +++ b/spec/services/members/update_service_spec.rb @@ -32,6 +32,33 @@ require "spec_helper" require "services/base_services/behaves_like_update_service" RSpec.describe Members::UpdateService, type: :model do + let(:user1) { build_stubbed(:user) } + let(:user2) { build_stubbed(:user) } + let(:child_group) do + build_stubbed(:group).tap do |g| + allow(g).to receive(:user_ids).and_return([user2.id]) + end + end + let(:group) do + build_stubbed(:group).tap do |g| + allow(g).to receive_messages(user_ids: [user1.id], self_and_descendants: [g, child_group]) + end + end + let!(:update_roles_service) do + instance_double(Groups::UpdateRolesService).tap do |service| + allow(Groups::UpdateRolesService) + .to receive(:new) + .and_return(service) + + allow(service) + .to receive(:call) + end + end + let!(:notifications) do + allow(OpenProject::Notifications) + .to receive(:send) + end + it_behaves_like "BaseServices update service" do let(:call_attributes) do { @@ -41,11 +68,6 @@ RSpec.describe Members::UpdateService, type: :model do } end - before do - allow(OpenProject::Notifications) - .to receive(:send) - end - describe "if successful" do it "sends a notification" do subject @@ -57,6 +79,32 @@ RSpec.describe Members::UpdateService, type: :model do message: call_attributes[:notification_message], send_notifications: call_attributes[:send_notifications]) end + + describe "for a group" do + let!(:model_instance) { build_stubbed(:member, principal: group) } + + it "calls UpdateRolesService with user_ids from self_and_descendants" do + subject + + expect(Groups::UpdateRolesService) + .to have_received(:new) + .with(group, current_user: user, contract_class: EmptyContract) + + expect(update_roles_service) + .to have_received(:call) + .with(member: model_instance, + user_ids: [user1.id, user2.id], + send_notifications: call_attributes[:send_notifications], + message: call_attributes[:notification_message]) + end + + it "does not send a notification" do + subject + + expect(OpenProject::Notifications) + .not_to have_received(:send) + end + end end context "if the SetAttributeService is unsuccessful" do @@ -68,6 +116,17 @@ RSpec.describe Members::UpdateService, type: :model do expect(OpenProject::Notifications) .not_to have_received(:send) end + + describe "for a group" do + let!(:model_instance) { build_stubbed(:member, principal: group) } + + it "does not call UpdateRolesService" do + subject + + expect(Groups::UpdateRolesService) + .not_to have_received(:new) + end + end end context "when the member is invalid" do @@ -79,6 +138,17 @@ RSpec.describe Members::UpdateService, type: :model do expect(OpenProject::Notifications) .not_to have_received(:send) end + + context "for a group" do + let!(:model_instance) { build_stubbed(:member, principal: group) } + + it "does not call UpdateRolesService" do + subject + + expect(Groups::UpdateRolesService) + .not_to have_received(:new) + end + end end end end From 79048de61f7ffcc4ee879c0431c58ceb9aa60722 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 10 Mar 2026 17:27:02 +0100 Subject: [PATCH 167/435] Add a `where_detail` scope helper to easily do `where` on the details table --- app/models/concerns/has_principal_details.rb | 4 ++ app/models/group.rb | 2 +- app/models/groups/hierarchy.rb | 2 +- .../groups/scopes/organizational_unit.rb | 41 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 app/models/groups/scopes/organizational_unit.rb diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index ddf978a65bc..9311fb84162 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -89,6 +89,10 @@ module HasPrincipalDetails scope :with_detail, -> { joins(association_name).includes(association_name) } + scope :where_detail, ->(**conditions) { + joins(association_name).where(detail_class.table_name => conditions) + } + # Validate the detail record and promote its errors onto the principal # so they appear as direct attributes (e.g. group.errors[:parent]). validate do diff --git a/app/models/group.rb b/app/models/group.rb index 8d678c4d20e..951539feab5 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -80,7 +80,7 @@ class Group < Principal :create_preference, :create_preference! - scopes :visible, :containing_user + scopes :visible, :containing_user, :organizational_unit # Columns required for formatting the group's name. def self.columns_for_name(_formatter = nil) diff --git a/app/models/groups/hierarchy.rb b/app/models/groups/hierarchy.rb index aac1be491d0..2de7aaca63b 100644 --- a/app/models/groups/hierarchy.rb +++ b/app/models/groups/hierarchy.rb @@ -33,7 +33,7 @@ module Groups::Hierarchy # Direct children of this group. def children - Group.joins(:group_detail).where(group_details: { parent_id: id }) + Group.where_detail(parent_id: id) end # All groups below this one in the tree (any depth). diff --git a/app/models/groups/scopes/organizational_unit.rb b/app/models/groups/scopes/organizational_unit.rb new file mode 100644 index 00000000000..e23b1058095 --- /dev/null +++ b/app/models/groups/scopes/organizational_unit.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Groups::Scopes + module OrganizationalUnit + extend ActiveSupport::Concern + + class_methods do + def organizational_units + where_detail(organizational_unit: true) + end + end + end +end From c8ebfc9ce6109301e8850f53520545104ceed8ae Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Mon, 16 Mar 2026 16:26:03 +0100 Subject: [PATCH 168/435] Move two classes to namespace of model using them They are the only two classes using the namespace called Wikis so far. We want to use this namespace for a module called `wikis` that will encapsulate integration with external wikis and the internal OpenProject wiki. --- app/models/wiki_page.rb | 4 ++-- app/models/{wikis => wiki_page}/annotate.rb | 2 +- app/models/{wikis => wiki_page}/diff.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename app/models/{wikis => wiki_page}/annotate.rb (98%) rename app/models/{wikis => wiki_page}/diff.rb (96%) diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 0e1d169a9e0..3636607cfda 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -161,7 +161,7 @@ class WikiPage < ApplicationRecord content_to = journals.find_by(version: version_to) content_from = journals.find_by(version: version_from) - content_to && content_from ? Wikis::Diff.new(content_to, content_from) : nil + content_to && content_from ? WikiPage::Diff.new(content_to, content_from) : nil end def version @@ -171,7 +171,7 @@ class WikiPage < ApplicationRecord def annotate(compare_version = nil) compare_version = compare_version ? compare_version.to_i : version c = journals.find_by(version: compare_version) - c ? Wikis::Annotate.new(c) : nil + c ? WikiPage::Annotate.new(c) : nil end # Returns true if usr is allowed to edit the page, otherwise false diff --git a/app/models/wikis/annotate.rb b/app/models/wiki_page/annotate.rb similarity index 98% rename from app/models/wikis/annotate.rb rename to app/models/wiki_page/annotate.rb index 5a9ea79240f..49bb99494b5 100644 --- a/app/models/wikis/annotate.rb +++ b/app/models/wiki_page/annotate.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Wikis::Annotate +class WikiPage::Annotate attr_reader :lines, :content def initialize(content) diff --git a/app/models/wikis/diff.rb b/app/models/wiki_page/diff.rb similarity index 96% rename from app/models/wikis/diff.rb rename to app/models/wiki_page/diff.rb index aa186cd88b5..b4f001e5ce7 100644 --- a/app/models/wikis/diff.rb +++ b/app/models/wiki_page/diff.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Wikis::Diff < Redmine::Helpers::Diff +class WikiPage::Diff < Redmine::Helpers::Diff attr_reader :content_to, :content_from def initialize(content_to, content_from) From 68ec2808e3ecae3d762e5afbf31520d75b1440f9 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Mon, 16 Mar 2026 16:44:04 +0100 Subject: [PATCH 169/435] Add empty wikis module This is a stub for all the work we want to perform on wiki integrations going forward. --- Gemfile.lock | 7 +++ Gemfile.modules | 1 + modules/wikis/README.md | 7 +++ modules/wikis/config/locales/en.yml | 7 +++ modules/wikis/lib/open_project/wikis.rb | 36 +++++++++++++++ .../wikis/lib/open_project/wikis/engine.rb | 46 +++++++++++++++++++ modules/wikis/lib/openproject-wikis.rb | 31 +++++++++++++ modules/wikis/openproject-wikis.gemspec | 44 ++++++++++++++++++ 8 files changed, 179 insertions(+) create mode 100644 modules/wikis/README.md create mode 100644 modules/wikis/config/locales/en.yml create mode 100644 modules/wikis/lib/open_project/wikis.rb create mode 100644 modules/wikis/lib/open_project/wikis/engine.rb create mode 100644 modules/wikis/lib/openproject-wikis.rb create mode 100644 modules/wikis/openproject-wikis.gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 32e52f8f59c..3671b95bae3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -213,6 +213,11 @@ PATH specs: openproject-webhooks (1.0.0) +PATH + remote: modules/wikis + specs: + openproject-wikis (1.0.0) + PATH remote: modules/xls_export specs: @@ -1687,6 +1692,7 @@ DEPENDENCIES openproject-token (~> 8.8.0) openproject-two_factor_authentication! openproject-webhooks! + openproject-wikis! openproject-xls_export! opentelemetry-exporter-otlp (~> 0.31.0) opentelemetry-instrumentation-all (~> 0.90.0) @@ -2068,6 +2074,7 @@ CHECKSUMS openproject-token (8.8.0) sha256=832a493e05dcce806134faf63ae8011cc5a48422fbed9ebb552f8028912954d4 openproject-two_factor_authentication (1.0.0) openproject-webhooks (1.0.0) + openproject-wikis (1.0.0) openproject-xls_export (1.0.0) openssl (4.0.1) sha256=e27974136b7b02894a1bce46c5397ee889afafe704a839446b54dc81cb9c5f7d openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80 diff --git a/Gemfile.modules b/Gemfile.modules index bf1f2f7b5b9..a548efe06a1 100644 --- a/Gemfile.modules +++ b/Gemfile.modules @@ -40,6 +40,7 @@ group :opf_plugins do gem 'openproject-gantt', path: 'modules/gantt' gem 'openproject-calendar', path: 'modules/calendar' gem 'openproject-storages', path: 'modules/storages' + gem 'openproject-wikis', path: 'modules/wikis' gem 'openproject-documents', path: 'modules/documents' gem 'openproject-bim', path: 'modules/bim' diff --git a/modules/wikis/README.md b/modules/wikis/README.md new file mode 100644 index 00000000000..2be37cd302b --- /dev/null +++ b/modules/wikis/README.md @@ -0,0 +1,7 @@ +# OpenProject Wikis Plugin + +FIXME Add description and check issue tracker link below + +## Issue Tracker + +https://community.openproject.org/projects/wikis/work_packages diff --git a/modules/wikis/config/locales/en.yml b/modules/wikis/config/locales/en.yml new file mode 100644 index 00000000000..efc34c0e46a --- /dev/null +++ b/modules/wikis/config/locales/en.yml @@ -0,0 +1,7 @@ +--- +en: + activerecord: + attributes: {} + errors: {} + models: {} + wikis: {} diff --git a/modules/wikis/lib/open_project/wikis.rb b/modules/wikis/lib/open_project/wikis.rb new file mode 100644 index 00000000000..d647a267736 --- /dev/null +++ b/modules/wikis/lib/open_project/wikis.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "open_project/wikis/engine" + +module OpenProject + module Wikis + end +end diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb new file mode 100644 index 00000000000..1d25f2c04bf --- /dev/null +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Prevent load-order problems in case openproject-plugins is listed after a plugin in the Gemfile +# or not at all +require "open_project/plugins" + +module OpenProject::Wikis + class Engine < ::Rails::Engine + engine_name :openproject_wikis + + include OpenProject::Plugins::ActsAsOpEngine + + register "openproject-wikis", + :author_url => "https://openproject.org", + :requires_openproject => ">= 17.0.0" + + end +end diff --git a/modules/wikis/lib/openproject-wikis.rb b/modules/wikis/lib/openproject-wikis.rb new file mode 100644 index 00000000000..adbe05a548f --- /dev/null +++ b/modules/wikis/lib/openproject-wikis.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "open_project/wikis" diff --git a/modules/wikis/openproject-wikis.gemspec b/modules/wikis/openproject-wikis.gemspec new file mode 100644 index 00000000000..2e12010266d --- /dev/null +++ b/modules/wikis/openproject-wikis.gemspec @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |s| + s.name = "openproject-wikis" + s.version = "1.0.0" + + s.authors = "OpenProject GmbH" + s.email = "info@openproject.org" + s.homepage = "https://community.openproject.org/projects/wikis" # TODO check this URL + s.summary = "OpenProject Wikis" + s.description = "Allows linking work packages to pages in wikis, such as XWiki or the internal OpenProject wiki." + s.license = "GPLv3" + + s.files = Dir["{app,config,db,lib}/**/*"] + %w(CHANGELOG.md README.md) +end From 4c61f81fd06120375ed3a02ff8c9f98fa319ce56 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 16 Mar 2026 17:16:26 +0100 Subject: [PATCH 170/435] Fix has_principal_details for the first usage of the class --- app/models/concerns/has_principal_details.rb | 27 ++++++++++++++++--- app/models/group.rb | 2 +- ...tional_unit.rb => organizational_units.rb} | 2 +- .../concerns/has_principal_details_spec.rb | 27 +++++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) rename app/models/groups/scopes/{organizational_unit.rb => organizational_units.rb} (98%) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index 9311fb84162..b1fd41e639f 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -116,13 +116,30 @@ module HasPrincipalDetails end def setup_detail_delegation(association_name, detail_class) - # Defer schema introspection until first instantiation so that tasks like - # db:create can load the model without a database connection being available. + # Try to set up delegation eagerly so that writer methods exist + # during assign_attributes in new/create. Requires DB + table. + if ActiveRecord::Base.connected? && detail_class.table_exists? + finalize_detail_delegation!(association_name, detail_class) + end + + # Fallback for when eager setup was skipped (db:create, db:migrate). + # finalize_detail_delegation! is idempotent via @_detail_delegation_set_up. after_initialize do self.class.send(:finalize_detail_delegation!, association_name, detail_class) end end + # Defines a writer method that auto-builds the detail record. + # This is necessary because `assign_attributes` runs before + # `after_initialize`, so `allow_nil: true` delegation would + # silently discard values when the detail hasn't been built yet. + def define_detail_writer(association_name, writer) + define_method(writer) do |value| + record = send(association_name) || send(:"build_#{association_name}") + record.send(writer, value) + end + end + def finalize_detail_delegation!(association_name, detail_class) return if @_detail_delegation_set_up @@ -130,7 +147,8 @@ module HasPrincipalDetails # Delegate all non-internal columns (detail_class.column_names - DETAIL_INTERNAL_COLUMNS).each do |col| - delegate col.to_sym, :"#{col}=", to: association_name, allow_nil: true + delegate col.to_sym, to: association_name + define_detail_writer(association_name, :"#{col}=") end # For belongs_to associations, also delegate the object reader/writer @@ -138,7 +156,8 @@ module HasPrincipalDetails detail_class.reflect_on_all_associations(:belongs_to).each do |reflection| next if reflection.name == model_name.element.to_sym # skip the back-reference - delegate reflection.name, :"#{reflection.name}=", to: association_name, allow_nil: true + delegate reflection.name, to: association_name + define_detail_writer(association_name, :"#{reflection.name}=") end end end diff --git a/app/models/group.rb b/app/models/group.rb index 951539feab5..89c36de8cb3 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -80,7 +80,7 @@ class Group < Principal :create_preference, :create_preference! - scopes :visible, :containing_user, :organizational_unit + scopes :visible, :containing_user, :organizational_units # Columns required for formatting the group's name. def self.columns_for_name(_formatter = nil) diff --git a/app/models/groups/scopes/organizational_unit.rb b/app/models/groups/scopes/organizational_units.rb similarity index 98% rename from app/models/groups/scopes/organizational_unit.rb rename to app/models/groups/scopes/organizational_units.rb index e23b1058095..e1741d7a369 100644 --- a/app/models/groups/scopes/organizational_unit.rb +++ b/app/models/groups/scopes/organizational_units.rb @@ -29,7 +29,7 @@ #++ module Groups::Scopes - module OrganizationalUnit + module OrganizationalUnits extend ActiveSupport::Concern class_methods do diff --git a/spec/models/concerns/has_principal_details_spec.rb b/spec/models/concerns/has_principal_details_spec.rb index 26afad26e63..51b2520f243 100644 --- a/spec/models/concerns/has_principal_details_spec.rb +++ b/spec/models/concerns/has_principal_details_spec.rb @@ -126,6 +126,33 @@ RSpec.describe HasPrincipalDetails do end end + describe "attribute assignment during creation" do + it "persists detail attributes passed to Group.create" do + created = Group.create!(lastname: "Creation Test", organizational_unit: true) + expect(created.reload.organizational_unit).to be true + end + + it "persists detail attributes passed to Group.new + save" do + new_group = Group.new(lastname: "New Test", organizational_unit: true) + expect(new_group.organizational_unit).to be true + + new_group.save! + expect(new_group.reload.organizational_unit).to be true + end + + it "persists belongs_to associations passed to Group.create" do + parent = create(:group) + created = Group.create!(lastname: "Child Group", parent:) + + expect(created.reload.parent).to eq(parent) + end + + it "defaults detail attributes to their column defaults when not specified" do + created = Group.create!(lastname: "Default Test") + expect(created.reload.organizational_unit).to be false + end + end + describe "error promotion" do it "promotes detail validation errors onto the principal" do group.parent_id = 0 From f3e5a3b3974211bef29cefbcd2e4ca2073375b0b Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 16 Mar 2026 18:04:52 +0100 Subject: [PATCH 171/435] Implement `dup` method to also copy over details --- app/models/concerns/has_principal_details.rb | 8 ++++++++ spec/models/concerns/has_principal_details_spec.rb | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index b1fd41e639f..df346e02c0e 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -35,6 +35,14 @@ module HasPrincipalDetails # and should not be delegated to the principal. DETAIL_INTERNAL_COLUMNS = %w[id principal_id created_at updated_at].freeze + # AR's dup doesn't copy associations, so the detail would be lost. + # Duplicate it so the copy behaves like a normal AR dup with all attributes. + def dup + super.tap do |copy| + copy.detail = detail.dup if detail.present? + end + end + class_methods do # Declares a detail table for this principal subclass. # The detail model class is generated automatically — no separate file needed. diff --git a/spec/models/concerns/has_principal_details_spec.rb b/spec/models/concerns/has_principal_details_spec.rb index 51b2520f243..e7774516dd5 100644 --- a/spec/models/concerns/has_principal_details_spec.rb +++ b/spec/models/concerns/has_principal_details_spec.rb @@ -81,6 +81,16 @@ RSpec.describe HasPrincipalDetails do it "aliases the concrete association to #detail" do expect(group.detail).to eq(group.group_detail) end + + it "duplicates the detail when the principal is dup'ed" do + group.update!(organizational_unit: true) + copy = group.dup + + expect(copy.detail).to be_present + expect(copy.detail).to be_new_record + expect(copy.detail.id).to be_nil + expect(copy.organizational_unit).to be true + end end describe "attribute delegation" do From f77df34fe986527a514efbd3c9c1146d405873a5 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 16 Mar 2026 18:11:32 +0100 Subject: [PATCH 172/435] Cleanup implementation for Rubocop --- app/models/concerns/has_principal_details.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index df346e02c0e..6d8b984b2be 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -153,14 +153,20 @@ module HasPrincipalDetails @_detail_delegation_set_up = true - # Delegate all non-internal columns + delegate_detail_columns(association_name, detail_class) + delegate_detail_associations(association_name, detail_class) + end + + def delegate_detail_columns(association_name, detail_class) (detail_class.column_names - DETAIL_INTERNAL_COLUMNS).each do |col| delegate col.to_sym, to: association_name define_detail_writer(association_name, :"#{col}=") end + end - # For belongs_to associations, also delegate the object reader/writer - # (columns like parent_id are already covered above) + # Delegate belongs_to object readers/writers from the detail. + # Column-level keys (e.g. parent_id) are already covered by delegate_detail_columns. + def delegate_detail_associations(association_name, detail_class) detail_class.reflect_on_all_associations(:belongs_to).each do |reflection| next if reflection.name == model_name.element.to_sym # skip the back-reference From b8d715c370178560c4907f0b141a548af0e28bc3 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 16 Mar 2026 18:20:40 +0100 Subject: [PATCH 173/435] Fix detail delegation to not pass around unneeded association_name --- app/models/concerns/has_principal_details.rb | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index 6d8b984b2be..6c1905c1d3f 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -65,7 +65,7 @@ module HasPrincipalDetails setup_detail_association(association_name, detail_class) setup_detail_aliases(association_name) - setup_detail_delegation(association_name, detail_class) + setup_detail_delegation(detail_class) end private @@ -123,17 +123,17 @@ module HasPrincipalDetails alias_method :build_detail, :"build_#{association_name}" end - def setup_detail_delegation(association_name, detail_class) + def setup_detail_delegation(detail_class) # Try to set up delegation eagerly so that writer methods exist # during assign_attributes in new/create. Requires DB + table. if ActiveRecord::Base.connected? && detail_class.table_exists? - finalize_detail_delegation!(association_name, detail_class) + finalize_detail_delegation!(detail_class) end # Fallback for when eager setup was skipped (db:create, db:migrate). # finalize_detail_delegation! is idempotent via @_detail_delegation_set_up. after_initialize do - self.class.send(:finalize_detail_delegation!, association_name, detail_class) + self.class.send(:finalize_detail_delegation!, detail_class) end end @@ -141,37 +141,37 @@ module HasPrincipalDetails # This is necessary because `assign_attributes` runs before # `after_initialize`, so `allow_nil: true` delegation would # silently discard values when the detail hasn't been built yet. - def define_detail_writer(association_name, writer) + def define_detail_writer(writer) define_method(writer) do |value| - record = send(association_name) || send(:"build_#{association_name}") - record.send(writer, value) + record = detail || build_detail + record.public_send(writer, value) end end - def finalize_detail_delegation!(association_name, detail_class) + def finalize_detail_delegation!(detail_class) return if @_detail_delegation_set_up @_detail_delegation_set_up = true - delegate_detail_columns(association_name, detail_class) - delegate_detail_associations(association_name, detail_class) + delegate_detail_columns(detail_class) + delegate_detail_associations(detail_class) end - def delegate_detail_columns(association_name, detail_class) + def delegate_detail_columns(detail_class) (detail_class.column_names - DETAIL_INTERNAL_COLUMNS).each do |col| - delegate col.to_sym, to: association_name - define_detail_writer(association_name, :"#{col}=") + delegate col.to_sym, to: :detail + define_detail_writer(:"#{col}=") end end # Delegate belongs_to object readers/writers from the detail. # Column-level keys (e.g. parent_id) are already covered by delegate_detail_columns. - def delegate_detail_associations(association_name, detail_class) + def delegate_detail_associations(detail_class) detail_class.reflect_on_all_associations(:belongs_to).each do |reflection| next if reflection.name == model_name.element.to_sym # skip the back-reference - delegate reflection.name, to: association_name - define_detail_writer(association_name, :"#{reflection.name}=") + delegate reflection.name, to: :detail + define_detail_writer(:"#{reflection.name}=") end end end From 29aa198554a1be5cf1e09f7f1adf5e850d9580ed Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 19:50:24 +0100 Subject: [PATCH 174/435] leverage primer css arguments --- .../settings/change_identifier_dialog_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/projects/settings/change_identifier_dialog_component.html.erb b/app/components/projects/settings/change_identifier_dialog_component.html.erb index 16fe257b255..1a7ac53fdde 100644 --- a/app/components/projects/settings/change_identifier_dialog_component.html.erb +++ b/app/components/projects/settings/change_identifier_dialog_component.html.erb @@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details. <%= settings_primer_form_with(model: project, url: project_identifier_path(project), id: "change-identifier-form", - class: "mt-4") do |f| %> + mt: 4) do |f| %> <%= render(Primer::Forms::FormList.new(Projects::Settings::EditableIdentifierForm.new(f))) %> <% end %> <% end %> From ea44b3620957ee69a0eedced2365544afaad6bdd Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 19:51:47 +0100 Subject: [PATCH 175/435] do not disable authorization for identifier_dialog --- app/controllers/projects_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 021b796467e..a03219dbbf0 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -46,7 +46,6 @@ class ProjectsController < ApplicationController before_action :find_optional_template, only: %i[new create] no_authorization_required! :index - no_authorization_required! :identifier_dialog include SortHelper include PaginationHelper From 811b23d6dc8791e2ec95e4b3ed55dbb031f85cdf Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 19:56:29 +0100 Subject: [PATCH 176/435] shorten the identifier spec --- .../projects/settings/general/show_component_spec.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spec/components/projects/settings/general/show_component_spec.rb b/spec/components/projects/settings/general/show_component_spec.rb index d0756303ce0..5574b8bfddd 100644 --- a/spec/components/projects/settings/general/show_component_spec.rb +++ b/spec/components/projects/settings/general/show_component_spec.rb @@ -76,9 +76,7 @@ RSpec.describe Projects::Settings::General::ShowComponent, type: :component do end end - describe "Identifier" do - before { with_flags(semantic_work_package_ids: true) } - + describe "Identifier", with_flag: { semantic_work_package_ids: true } do it_behaves_like "section with heading", "Identifier" it "renders a Change identifier button" do From ce53fd00307c82fdb250db208cc49c56b5071c1e Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 20:18:30 +0100 Subject: [PATCH 177/435] get rid of the old-style identifier setting entirely --- .../settings/general/show_component.html.erb | 34 ++++---- .../index_page_header_component.html.erb | 15 ---- .../projects/identifier_controller.rb | 2 - app/controllers/projects_controller.rb | 4 +- .../settings/editable_identifier_form.rb | 2 +- .../projects/settings/identifier_form.rb | 2 +- app/models/project.rb | 12 ++- app/views/projects/identifier/show.html.erb | 84 ------------------- config/initializers/permissions.rb | 1 + 9 files changed, 25 insertions(+), 131 deletions(-) delete mode 100644 app/views/projects/identifier/show.html.erb diff --git a/app/components/projects/settings/general/show_component.html.erb b/app/components/projects/settings/general/show_component.html.erb index 0d8db5c69c6..1d5f8e97ecf 100644 --- a/app/components/projects/settings/general/show_component.html.erb +++ b/app/components/projects/settings/general/show_component.html.erb @@ -46,24 +46,22 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> -<% if OpenProject::FeatureDecisions.semantic_work_package_ids_active? %> - <%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %> - <%= - render(Primer::Beta::Subhead.new) do |component| - component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) } - end - %> - <%= - settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f| - render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f))) - end - %> - <%= render(Primer::Beta::Button.new( - tag: :a, - href: projects_identifier_dialog_path(project_id: project), - data: { turbo_stream: true } - )) { t("projects.settings.change_identifier") } %> - <% end %> +<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %> + <%= + render(Primer::Beta::Subhead.new) do |component| + component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) } + end + %> + <%= + settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f| + render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f))) + end + %> + <%= render(Primer::Beta::Button.new( + tag: :a, + href: projects_identifier_dialog_path(project_id: project), + data: { turbo_stream: true } + )) { t("projects.settings.change_identifier") } %> <% end %> diff --git a/app/components/projects/settings/index_page_header_component.html.erb b/app/components/projects/settings/index_page_header_component.html.erb index 08aed6f0fb2..3a6d55f13eb 100644 --- a/app/components/projects/settings/index_page_header_component.html.erb +++ b/app/components/projects/settings/index_page_header_component.html.erb @@ -24,21 +24,6 @@ end end - unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? - header.with_action_button( - tag: :a, - mobile_icon: :pencil, - mobile_label: t("projects.settings.change_identifier"), - size: :medium, - href: project_identifier_path(@project), - aria: { label: t("projects.settings.change_identifier") }, - title: t("projects.settings.change_identifier") - ) do |button| - button.with_leading_visual_icon(icon: :pencil) - t("projects.settings.change_identifier") - end - end - header.with_action_menu( menu_arguments: { anchor_align: :end diff --git a/app/controllers/projects/identifier_controller.rb b/app/controllers/projects/identifier_controller.rb index 99f1c5601b8..cd12db8116e 100644 --- a/app/controllers/projects/identifier_controller.rb +++ b/app/controllers/projects/identifier_controller.rb @@ -34,8 +34,6 @@ class Projects::IdentifierController < ApplicationController before_action :find_project_by_project_id before_action :authorize - def show; end - def update service_call = Projects::UpdateService .new(user: current_user, diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a03219dbbf0..4d4f08d4807 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -39,7 +39,7 @@ class ProjectsController < ApplicationController before_action :find_project_including_archived, only: %i[destroy destroy_info] before_action :load_query_or_deny_access, only: %i[index] before_action :authorize, - only: %i[copy_form copy deactivate_work_package_attachments export_project_initiation_pdf] + only: %i[copy_form copy deactivate_work_package_attachments export_project_initiation_pdf identifier_dialog] before_action :authorize_global, only: %i[new create] before_action :require_admin, only: %i[destroy destroy_info] before_action :find_optional_parent, only: :new @@ -163,8 +163,6 @@ class ProjectsController < ApplicationController end def identifier_dialog - return render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? - respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project) end diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb index 15578152518..b1e53d61d77 100644 --- a/app/forms/projects/settings/editable_identifier_form.rb +++ b/app/forms/projects/settings/editable_identifier_form.rb @@ -31,7 +31,7 @@ module Projects module Settings class EditableIdentifierForm < ApplicationForm form do |f| - if Project.semantic_alphanumeric_identifier? + if Setting::WorkPackageIdentifier.alphanumeric? f.text_field( name: :identifier, label: attribute_name(:identifier), diff --git a/app/forms/projects/settings/identifier_form.rb b/app/forms/projects/settings/identifier_form.rb index de257e820d1..872e8de891d 100644 --- a/app/forms/projects/settings/identifier_form.rb +++ b/app/forms/projects/settings/identifier_form.rb @@ -31,7 +31,7 @@ module Projects module Settings class IdentifierForm < ApplicationForm form do |f| - caption_key = if Project.semantic_alphanumeric_identifier? + caption_key = if Setting::WorkPackageIdentifier.alphanumeric? :text_project_identifier_description else :text_project_identifier_url_description diff --git a/app/models/project.rb b/app/models/project.rb index df496833f6d..bf991f41219 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -211,18 +211,20 @@ class Project < ApplicationRecord # Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id. validates :identifier, format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ }, - if: ->(p) { p.identifier_changed? && p.identifier.present? && !Project.semantic_alphanumeric_identifier? } + if: ->(p) { + p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric? + } # When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules. validates :identifier, format: { with: /\A[A-Z]/, message: :must_start_with_letter }, - if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? } + if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } validates :identifier, format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH }, if: ->(p) { - p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? && + p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? && p.identifier.match?(/\A[A-Z]/) } @@ -280,10 +282,6 @@ class Project < ApplicationRecord User.current.allowed_in_project?(:copy_projects, self) end - def self.semantic_alphanumeric_identifier? - OpenProject::FeatureDecisions.semantic_work_package_ids_active? && Setting::WorkPackageIdentifier.alphanumeric? - end - def self.selectable_projects Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s) end diff --git a/app/views/projects/identifier/show.html.erb b/app/views/projects/identifier/show.html.erb deleted file mode 100644 index a0209aa6c83..00000000000 --- a/app/views/projects/identifier/show.html.erb +++ /dev/null @@ -1,84 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> - -<%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title { t("project.identifier.title") } - header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, - t("project.identifier.title")] - ) - end -%> - -<%= error_messages_for @project %> - -<%= form_for @project, - url: project_identifier_path(@project), - html: { class: "danger-zone form -vertical" } do |f| %> -
-

- <%= t("project.identifier.title") %> -

- -

- - <%= t("project.identifier.warning_one").html_safe %> -
- - <%= t("project.identifier.warning_two").html_safe %> -

- - <%= styled_label_tag "identifier", Project.human_attribute_name(:identifier), class: "-required" %> -
- <%= f.text_field :identifier %> - - <%= f.submit t(:button_update), class: "button -primary -with-icon icon-checkmark" %> - - - <%= link_to project_settings_general_path(@project), class: "button" do %> - <%= op_icon("button--icon icon-cancel") %> - <%= t(:button_cancel) %> - <% end %> - -
- -
- -
- <%= t( - :text_length_between, min: 1, - max: Project::IDENTIFIER_MAX_LENGTH - ) %> - <%= t(:text_project_identifier_info).html_safe %> -
- -
-<% end %> diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 528298b7196..4d0bb25ed98 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -138,6 +138,7 @@ Rails.application.reloader.to_prepare do "projects/settings/template": %i[show update toggle_template], "projects/templated": %i[create destroy], "projects/identifier": %i[show update update_identifier_dialog], + projects: %i[identifier_dialog], "projects/status": %i[update destroy] }, permissible_on: :project, From 8a4e3f83e82195cf5f437b8f5f3e8e56dce231c4 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 20:29:17 +0100 Subject: [PATCH 178/435] move the identifier dialog action to identifier controller --- .../projects/settings/general/show_component.html.erb | 2 +- app/controllers/projects/identifier_controller.rb | 8 +++++--- app/controllers/projects_controller.rb | 9 ++------- app/models/project.rb | 2 +- config/initializers/permissions.rb | 3 +-- config/routes.rb | 7 +++---- 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/app/components/projects/settings/general/show_component.html.erb b/app/components/projects/settings/general/show_component.html.erb index 1d5f8e97ecf..f9f94c89711 100644 --- a/app/components/projects/settings/general/show_component.html.erb +++ b/app/components/projects/settings/general/show_component.html.erb @@ -59,7 +59,7 @@ See COPYRIGHT and LICENSE files for more details. %> <%= render(Primer::Beta::Button.new( tag: :a, - href: projects_identifier_dialog_path(project_id: project), + href: identifier_update_dialog_project_identifier_path(project_id: project), data: { turbo_stream: true } )) { t("projects.settings.change_identifier") } %> <% end %> diff --git a/app/controllers/projects/identifier_controller.rb b/app/controllers/projects/identifier_controller.rb index cd12db8116e..6b8d2b3416b 100644 --- a/app/controllers/projects/identifier_controller.rb +++ b/app/controllers/projects/identifier_controller.rb @@ -34,6 +34,10 @@ class Projects::IdentifierController < ApplicationController before_action :find_project_by_project_id before_action :authorize + def identifier_update_dialog + respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project) + end + def update service_call = Projects::UpdateService .new(user: current_user, @@ -43,11 +47,9 @@ class Projects::IdentifierController < ApplicationController if service_call.success? flash[:notice] = I18n.t(:notice_successful_update) redirect_to project_settings_general_path(@project) - elsif OpenProject::FeatureDecisions.semantic_work_package_ids_active? # Handle error for the new modal + else respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project), status: :unprocessable_entity - else # Handle error for the legacy standalone identifier setting page - render action: "show", status: :unprocessable_entity end end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 4d4f08d4807..124afd6d046 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -34,12 +34,11 @@ class ProjectsController < ApplicationController menu_item :overview menu_item :roadmap, only: :roadmap - before_action :find_project, except: %i[index new create destroy destroy_info identifier_dialog] - before_action :find_project_by_project_id, only: %i[identifier_dialog] + before_action :find_project, except: %i[index new create destroy destroy_info] before_action :find_project_including_archived, only: %i[destroy destroy_info] before_action :load_query_or_deny_access, only: %i[index] before_action :authorize, - only: %i[copy_form copy deactivate_work_package_attachments export_project_initiation_pdf identifier_dialog] + only: %i[copy_form copy deactivate_work_package_attachments export_project_initiation_pdf] before_action :authorize_global, only: %i[new create] before_action :require_admin, only: %i[destroy destroy_info] before_action :find_optional_parent, only: :new @@ -162,10 +161,6 @@ class ProjectsController < ApplicationController respond_with_dialog Projects::DeleteDialogComponent.new(project: @project) end - def identifier_dialog - respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project) - end - def deactivate_work_package_attachments call = Projects::UpdateService .new(user: current_user, model: @project, contract_class: Projects::SettingsContract) diff --git a/app/models/project.rb b/app/models/project.rb index bf991f41219..0291361a632 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -48,7 +48,7 @@ class Project < ApplicationRecord SEMANTIC_IDENTIFIER_MAX_LENGTH = 10 # reserved identifiers - RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog].freeze + RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog].freeze enum :workspace_type, { project: "project", diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 4d0bb25ed98..232480aa5cc 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -137,8 +137,7 @@ Rails.application.reloader.to_prepare do "projects/settings/subitems": %i[show update], "projects/settings/template": %i[show update toggle_template], "projects/templated": %i[create destroy], - "projects/identifier": %i[show update update_identifier_dialog], - projects: %i[identifier_dialog], + "projects/identifier": %i[show update identifier_update_dialog], "projects/status": %i[update destroy] }, permissible_on: :project, diff --git a/config/routes.rb b/config/routes.rb index b9d0e567674..76ec0214434 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -283,9 +283,6 @@ Rails.application.routes.draw do namespace :projects do resource :menu, only: %i[show] resource :filters, only: %i[show] - get "identifier_dialog", to: "/projects#identifier_dialog", - as: :identifier_dialog, - defaults: { format: :turbo_stream } end %w[portfolio project program].each do |workspace_type| @@ -357,7 +354,9 @@ Rails.application.routes.draw do get :dialog end end - resource :identifier, only: %i[show update], controller: "identifier" + resource :identifier, only: %i[show update], controller: "identifier" do + get :identifier_update_dialog, on: :member, defaults: { format: :turbo_stream } + end resource :status, only: %i[update destroy], controller: "status" resource :creation_wizard, only: %i[show update], controller: "creation_wizard" do get :help_text, on: :member From b8f59a3a2474d34ba6edc1427325d1a0b9c814d6 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 20:35:09 +0100 Subject: [PATCH 179/435] Fix a test --- app/controllers/projects/identifier_controller.rb | 2 +- spec/controllers/projects/identifier_controller_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/projects/identifier_controller.rb b/app/controllers/projects/identifier_controller.rb index 6b8d2b3416b..2309ea759d3 100644 --- a/app/controllers/projects/identifier_controller.rb +++ b/app/controllers/projects/identifier_controller.rb @@ -48,7 +48,7 @@ class Projects::IdentifierController < ApplicationController flash[:notice] = I18n.t(:notice_successful_update) redirect_to project_settings_general_path(@project) else - respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project), + respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: service_call.result), status: :unprocessable_entity end end diff --git a/spec/controllers/projects/identifier_controller_spec.rb b/spec/controllers/projects/identifier_controller_spec.rb index 8bd94389e66..80fbb45a46c 100644 --- a/spec/controllers/projects/identifier_controller_spec.rb +++ b/spec/controllers/projects/identifier_controller_spec.rb @@ -48,10 +48,10 @@ RSpec.describe Projects::IdentifierController do context "with an invalid identifier" do it "does not change the project identifier and correctly renders the view" do previous_identifier = project.identifier - put :update, params: { project_id: project.id, project: { identifier: "bad identifier" } } + put :update, params: { project_id: project.id, project: { identifier: "bad identifier" }, format: :turbo_stream } expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to include("Identifier is invalid") + expect(response.body).to include("is invalid") expect(project.reload.identifier).to eq(previous_identifier) end end From 7027cb57346fe319eec4afee65f89c5d3aa74b8b Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 20:47:57 +0100 Subject: [PATCH 180/435] lint --- .../settings/general/show_component.html.erb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/components/projects/settings/general/show_component.html.erb b/app/components/projects/settings/general/show_component.html.erb index f9f94c89711..4599126fc76 100644 --- a/app/components/projects/settings/general/show_component.html.erb +++ b/app/components/projects/settings/general/show_component.html.erb @@ -57,14 +57,18 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f))) end %> - <%= render(Primer::Beta::Button.new( - tag: :a, - href: identifier_update_dialog_project_identifier_path(project_id: project), - data: { turbo_stream: true } - )) { t("projects.settings.change_identifier") } %> + <%= + render( + Primer::Beta::Button.new( + tag: :a, + href: identifier_update_dialog_project_identifier_path(project_id: project), + data: { turbo_stream: true }, + mt: 2 + ) + ) { t("projects.settings.change_identifier") } + %> <% end %> - <%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %> <%= render(Primer::Beta::Subhead.new) do |component| From 42c79fe19ae54f8b438d63c647df0711ac4a9f2b Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:08:23 +0100 Subject: [PATCH 181/435] switch to server-only ID length validation --- app/forms/projects/settings/editable_identifier_form.rb | 4 +--- spec/controllers/projects/identifier_controller_spec.rb | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb index b1e53d61d77..f929e1bd6f0 100644 --- a/app/forms/projects/settings/editable_identifier_form.rb +++ b/app/forms/projects/settings/editable_identifier_form.rb @@ -37,7 +37,6 @@ module Projects label: attribute_name(:identifier), caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), required: true, - maxlength: Project::SEMANTIC_IDENTIFIER_MAX_LENGTH, validation_message: validation_message_for(:identifier) ) else @@ -46,7 +45,6 @@ module Projects label: attribute_name(:identifier), caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), required: true, - maxlength: Project::IDENTIFIER_MAX_LENGTH, validation_message: validation_message_for(:identifier) ) end @@ -55,7 +53,7 @@ module Projects private def validation_message_for(attribute) - model.errors.messages_for(attribute).to_sentence.presence + model.errors.full_messages_for(attribute).to_sentence.presence end end end diff --git a/spec/controllers/projects/identifier_controller_spec.rb b/spec/controllers/projects/identifier_controller_spec.rb index 80fbb45a46c..e013a56683c 100644 --- a/spec/controllers/projects/identifier_controller_spec.rb +++ b/spec/controllers/projects/identifier_controller_spec.rb @@ -51,7 +51,7 @@ RSpec.describe Projects::IdentifierController do put :update, params: { project_id: project.id, project: { identifier: "bad identifier" }, format: :turbo_stream } expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to include("is invalid") + expect(response.body).to include("Identifier is invalid") expect(project.reload.identifier).to eq(previous_identifier) end end From 3d2ab6de06cb976d9957feb833604ab68fea9d0a Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:08:29 +0100 Subject: [PATCH 182/435] remove unneeded tests --- spec/features/projects/edit_settings_spec.rb | 129 +++++++------------ 1 file changed, 49 insertions(+), 80 deletions(-) diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index c677f3ba591..e9ee276747c 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -48,106 +48,75 @@ RSpec.describe "Projects", "editing settings", :js do visit project_settings_general_path(project.id) 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 - visit projects_path - click_on project.name - click_on "Project settings" - click_on "Change identifier" + context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do + it "updates the project identifier via dialog" do + visit project_settings_general_path(project) - expect(page).to have_content "Change the project's identifier".upcase - expect(page).to have_current_path "/projects/foo-project/identifier" + click_on "Change identifier" - fill_in "project[identifier]", with: "foo-bar" - click_on "Update" + expect(page).to have_dialog "Change project identifier" - 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" + within "dialog" do + expect(page).to have_text "This will permanently change identifiers and URLs" + fill_in "project[identifier]", with: "foo-bar" + click_on "Change identifier" + end + + expect(page).to have_content "Successful update." + expect(page).to have_current_path %r{/projects/foo-bar/settings/general} + expect(project.reload.identifier).to eq "foo-bar" + end end - it "displays error messages on invalid input" do - visit project_identifier_path(project) + context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do + it "updates the project identifier via dialog" do + visit project_settings_general_path(project) - fill_in "project[identifier]", with: "FOOO" - click_on "Update" + click_on "Change identifier" - expect(page).to have_content "Identifier is invalid." - expect(page).to have_current_path "/projects/foo-project/identifier" - end + expect(page).to have_dialog "Change project identifier" - context "with the semantic work package IDs flag enabled", with_flag: { semantic_work_package_ids: true } do - context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do - it "updates the project identifier via dialog" do - visit project_settings_general_path(project) + within "dialog" do + expect(page).to have_text "This will permanently change identifiers and URLs" + fill_in "project[identifier]", with: "FOOBAR" + click_on "Change identifier" + end + expect(page).to have_content "Successful update." + expect(page).to have_current_path %r{/projects/FOOBAR/settings/general} + expect(project.reload.identifier).to eq "FOOBAR" + end + + it "displays an error when the identifier does not start with a letter" do + visit project_settings_general_path(project) + + click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + fill_in "project[identifier]", with: "123ABC" click_on "Change identifier" - expect(page).to have_dialog "Change project identifier" - - within "dialog" do - expect(page).to have_text "This will permanently change identifiers and URLs" - fill_in "project[identifier]", with: "foo-bar" - click_on "Change identifier" - end - - expect(page).to have_content "Successful update." - expect(page).to have_current_path %r{/projects/foo-bar/settings/general} - expect(project.reload.identifier).to eq "foo-bar" + expect(page).to have_text "The first character has to be a letter." end end - context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do - it "updates the project identifier via dialog" do - visit project_settings_general_path(project) + it "displays an error when the identifier contains special characters" do + visit project_settings_general_path(project) + click_on "Change identifier" + + expect(page).to have_dialog "Change project identifier" + + within "dialog" do + fill_in "project[identifier]", with: "FOO@BAR" click_on "Change identifier" - expect(page).to have_dialog "Change project identifier" - - within "dialog" do - expect(page).to have_text "This will permanently change identifiers and URLs" - fill_in "project[identifier]", with: "FOOBAR" - click_on "Change identifier" - end - - expect(page).to have_content "Successful update." - expect(page).to have_current_path %r{/projects/FOOBAR/settings/general} - expect(project.reload.identifier).to eq "FOOBAR" - end - - it "displays an error when the identifier does not start with a letter" do - visit project_settings_general_path(project) - - click_on "Change identifier" - - expect(page).to have_dialog "Change project identifier" - - within "dialog" do - fill_in "project[identifier]", with: "123ABC" - click_on "Change identifier" - - expect(page).to have_text "The first character has to be a letter." - end - end - - it "displays an error when the identifier contains special characters" do - visit project_settings_general_path(project) - - click_on "Change identifier" - - expect(page).to have_dialog "Change project identifier" - - within "dialog" do - fill_in "project[identifier]", with: "FOO@BAR" - click_on "Change identifier" - - expect(page).to have_text "Special characters not allowed." - end + expect(page).to have_text "Special characters not allowed." end end end From 23d8a18923e71062fe609cf48173712b625ca857 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:11:00 +0100 Subject: [PATCH 183/435] lint --- app/models/project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 0291361a632..2406a05b1b7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -212,7 +212,7 @@ class Project < ApplicationRecord validates :identifier, format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ }, if: ->(p) { - p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric? + p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric? } # When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules. From 72fa065475b52d9225f9d9ed75c0e03a18145834 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:19:12 +0100 Subject: [PATCH 184/435] switch to the new method --- app/controllers/projects/identifier_suggestions_controller.rb | 4 ---- app/models/project.rb | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/controllers/projects/identifier_suggestions_controller.rb b/app/controllers/projects/identifier_suggestions_controller.rb index 4a948582649..f80e8aeaad0 100644 --- a/app/controllers/projects/identifier_suggestions_controller.rb +++ b/app/controllers/projects/identifier_suggestions_controller.rb @@ -34,10 +34,6 @@ module Projects no_authorization_required! :show def show - unless OpenProject::FeatureDecisions.semantic_work_package_ids_active? - render json: {}, status: :not_found and return - end - name = params[:name].to_s.strip return render json: {}, status: :unprocessable_entity if name.blank? diff --git a/app/models/project.rb b/app/models/project.rb index c84de9e605b..d19bea56136 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -284,7 +284,7 @@ class Project < ApplicationRecord def self.suggest_identifier(name) if Setting::WorkPackageIdentifier.alphanumeric? - WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.suggest_for_name(name) + WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.suggest_identifier(name) else name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project" end From 740459a431c6583bcf2d0755c35c4153f2d9d90c Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:23:07 +0100 Subject: [PATCH 185/435] remove feature flags from the tests --- .../projects/identifier_suggestions_spec.rb | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/spec/requests/projects/identifier_suggestions_spec.rb b/spec/requests/projects/identifier_suggestions_spec.rb index bda382bb704..869839e8b83 100644 --- a/spec/requests/projects/identifier_suggestions_spec.rb +++ b/spec/requests/projects/identifier_suggestions_spec.rb @@ -33,18 +33,7 @@ require "rails_helper" RSpec.describe "GET /projects/identifier_suggestion", type: :rails_request do current_user { create(:user) } - context "when the feature flag is off" do - before { with_flags(semantic_work_package_ids: false) } - - it "returns 404" do - get "/projects/identifier_suggestion", params: { name: "My Project" }, as: :json - expect(response).to have_http_status(:not_found) - end - end - - context "when the feature flag is on" do - before { with_flags(semantic_work_package_ids: true) } - + context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do it "returns a suggested identifier derived from the name" do get "/projects/identifier_suggestion", params: { name: "Flight Planning Algorithm" }, as: :json expect(response).to have_http_status(:ok) From b1fff98b3aa80bdaf75f83126913a101b8fb65a1 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:29:56 +0100 Subject: [PATCH 186/435] EditableIdentifierForm => IdentifierForm --- .../projects/new_component.html.erb | 2 +- ...hange_identifier_dialog_component.html.erb | 2 +- .../settings/editable_identifier_form.rb | 60 ------------------- .../projects/settings/identifier_form.rb | 34 +++++++---- ...r_form_spec.rb => identifier_form_spec.rb} | 2 +- 5 files changed, 26 insertions(+), 74 deletions(-) delete mode 100644 app/forms/projects/settings/editable_identifier_form.rb rename spec/forms/projects/settings/{editable_identifier_form_spec.rb => identifier_form_spec.rb} (97%) diff --git a/app/components/projects/new_component.html.erb b/app/components/projects/new_component.html.erb index af5304489ba..db53271dd76 100644 --- a/app/components/projects/new_component.html.erb +++ b/app/components/projects/new_component.html.erb @@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details. render( Primer::Forms::FormList.new( Projects::Settings::NameForm.new(f), - Projects::Settings::EditableIdentifierForm.new(f), + Projects::Settings::IdentifierForm.new(f), Projects::Settings::DescriptionForm.new(f), Projects::Settings::RelationsForm.new(f, invisible: params[:parent_id].present?), Projects::Settings::TypeForm.new(f) diff --git a/app/components/projects/settings/change_identifier_dialog_component.html.erb b/app/components/projects/settings/change_identifier_dialog_component.html.erb index 1a7ac53fdde..5a139ceadd2 100644 --- a/app/components/projects/settings/change_identifier_dialog_component.html.erb +++ b/app/components/projects/settings/change_identifier_dialog_component.html.erb @@ -40,7 +40,7 @@ See COPYRIGHT and LICENSE files for more details. url: project_identifier_path(project), id: "change-identifier-form", mt: 4) do |f| %> - <%= render(Primer::Forms::FormList.new(Projects::Settings::EditableIdentifierForm.new(f))) %> + <%= render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f))) %> <% end %> <% end %> diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb deleted file mode 100644 index f929e1bd6f0..00000000000 --- a/app/forms/projects/settings/editable_identifier_form.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ -module Projects - module Settings - class EditableIdentifierForm < ApplicationForm - form do |f| - if Setting::WorkPackageIdentifier.alphanumeric? - f.text_field( - name: :identifier, - label: attribute_name(:identifier), - caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), - required: true, - validation_message: validation_message_for(:identifier) - ) - else - f.text_field( - name: :identifier, - label: attribute_name(:identifier), - caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), - required: true, - validation_message: validation_message_for(:identifier) - ) - end - end - - private - - def validation_message_for(attribute) - model.errors.full_messages_for(attribute).to_sentence.presence - end - end - end -end diff --git a/app/forms/projects/settings/identifier_form.rb b/app/forms/projects/settings/identifier_form.rb index 872e8de891d..259bb662979 100644 --- a/app/forms/projects/settings/identifier_form.rb +++ b/app/forms/projects/settings/identifier_form.rb @@ -31,17 +31,29 @@ module Projects module Settings class IdentifierForm < ApplicationForm form do |f| - caption_key = if Setting::WorkPackageIdentifier.alphanumeric? - :text_project_identifier_description - else - :text_project_identifier_url_description - end - f.text_field( - name: :identifier, - label: attribute_name(:identifier), - caption: I18n.t(caption_key), - disabled: true - ) + if Setting::WorkPackageIdentifier.alphanumeric? + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), + required: true, + validation_message: validation_message_for(:identifier) + ) + else + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), + required: true, + validation_message: validation_message_for(:identifier) + ) + end + end + + private + + def validation_message_for(attribute) + model.errors.full_messages_for(attribute).to_sentence.presence end end end diff --git a/spec/forms/projects/settings/editable_identifier_form_spec.rb b/spec/forms/projects/settings/identifier_form_spec.rb similarity index 97% rename from spec/forms/projects/settings/editable_identifier_form_spec.rb rename to spec/forms/projects/settings/identifier_form_spec.rb index 45cdce0f11f..20e614cdc16 100644 --- a/spec/forms/projects/settings/editable_identifier_form_spec.rb +++ b/spec/forms/projects/settings/identifier_form_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do +RSpec.describe Projects::Settings::IdentifierForm, type: :forms do include_context "with rendered form" let(:model) { build_stubbed(:project, identifier: "my-project") } From 734becc6dcd9f2d1e70740a3f515fd7a34d0f878 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:41:04 +0100 Subject: [PATCH 187/435] ditch feature flag & unify error message format --- app/components/projects/new_component.rb | 7 ++++--- config/locales/en.yml | 6 +++--- lib_static/open_project/feature_decisions.rb | 4 ++++ spec/features/projects/edit_settings_spec.rb | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/components/projects/new_component.rb b/app/components/projects/new_component.rb index ee11c72f2ce..34c6088ce67 100644 --- a/app/components/projects/new_component.rb +++ b/app/components/projects/new_component.rb @@ -45,12 +45,13 @@ module Projects end def identifier_suggestion_data - flag_active = OpenProject::FeatureDecisions.semantic_work_package_ids_active? + suggestion_mode = Setting::WorkPackageIdentifier.alphanumeric? ? "semantic" : "legacy" + data = { controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": Project.semantic_alphanumeric_identifier? ? "semantic" : "legacy" + "projects--identifier-suggestion-mode-value": suggestion_mode } - data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if flag_active + data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path data end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8e89c0b2c24..30a7ebce8d4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -837,7 +837,7 @@ en: Sub-projects are not affected and have their own settings. change_identifier: Change identifier change_identifier_dialog_title: Change project identifier - change_identifier_format_hint_semantic: "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. The first character has to be a letter." + change_identifier_format_hint_semantic: "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. Must start with a letter." change_identifier_format_hint_legacy: "Only lowercase letters (a–z), numbers, dashes or underscores." change_identifier_warning: > This will permanently change identifiers and URLs of all work packages in this project. @@ -2023,8 +2023,8 @@ en: types: in_use_by_work_packages: "still in use by work packages: %{types}" identifier: - must_start_with_letter: "The first character has to be a letter." - no_special_characters: "Special characters not allowed." + must_start_with_letter: "must start with a letter" + no_special_characters: "may only contain uppercase letters, numbers, and underscores" enabled_modules: dependency_missing: "The module '%{dependency}' needs to be enabled as well since the module '%{module}' depends on it." format: "%{message}" diff --git a/lib_static/open_project/feature_decisions.rb b/lib_static/open_project/feature_decisions.rb index 8a30ac6fb46..cb2f06b09cc 100644 --- a/lib_static/open_project/feature_decisions.rb +++ b/lib_static/open_project/feature_decisions.rb @@ -112,5 +112,9 @@ module OpenProject writable: !force_active, disallow_override: force_active end + + def self.semantic_work_package_ids_active? + # code here + end end end diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index e9ee276747c..d5a27d87b23 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -101,7 +101,7 @@ RSpec.describe "Projects", "editing settings", :js do fill_in "project[identifier]", with: "123ABC" click_on "Change identifier" - expect(page).to have_text "The first character has to be a letter." + expect(page).to have_text "Identifier must start with a letter" end end From cfaa2798b4398923c78b4e707ce0984801419aaa Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 21:51:53 +0100 Subject: [PATCH 188/435] get rid of the broken i8n message upon refresh --- app/components/projects/new_component.rb | 8 ++++---- .../dynamic/projects/identifier-suggestion.controller.ts | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/components/projects/new_component.rb b/app/components/projects/new_component.rb index 34c6088ce67..b646a3f0edf 100644 --- a/app/components/projects/new_component.rb +++ b/app/components/projects/new_component.rb @@ -47,12 +47,12 @@ module Projects def identifier_suggestion_data suggestion_mode = Setting::WorkPackageIdentifier.alphanumeric? ? "semantic" : "legacy" - data = { + { controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": suggestion_mode + "projects--identifier-suggestion-mode-value": suggestion_mode, + "projects--identifier-suggestion-url-value": projects_identifier_suggestion_path, + "projects--identifier-suggestion-set-name-first-value": I18n.t("js.projects.identifier_suggestion.set_name_first") } - data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path - data end def workspaces_path diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts index 319e691c6b4..95e18af51c5 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -41,11 +41,13 @@ export default class extends Controller { url: String, debounce: {type: Number, default: 300}, mode: {type: String, default: 'legacy'}, + setNameFirst: {type: String, default: ''}, }; declare urlValue:string; declare debounceValue:number; declare modeValue:string; + declare setNameFirstValue:string; private nameInput:HTMLInputElement | null = null; private identifierInput:HTMLInputElement | null = null; @@ -64,7 +66,7 @@ export default class extends Controller { if (this.urlValue) { if (!this.identifierInput.value) { - this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.set_name_first'); + this.identifierInput.placeholder = this.setNameFirstValue; this.identifierInput.readOnly = true; } From 69561040e2aea47fec1a5128dfd3e1cc1c6a7dfe Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:16:30 +0100 Subject: [PATCH 189/435] add more tests --- .../components/projects/new_component_spec.rb | 30 ++++++++++++++ spec/features/projects/create_spec.rb | 39 ++++++++++++++++++ spec/models/project_spec.rb | 40 +++++++++++++++++++ .../projects/identifier_suggestions_spec.rb | 31 ++++++++++++++ 4 files changed, 140 insertions(+) diff --git a/spec/components/projects/new_component_spec.rb b/spec/components/projects/new_component_spec.rb index 674791a2dd8..0fb1a4e946a 100644 --- a/spec/components/projects/new_component_spec.rb +++ b/spec/components/projects/new_component_spec.rb @@ -81,4 +81,34 @@ RSpec.describe Projects::NewComponent, type: :component do expect(Projects::Settings::CustomFieldsForm).to have_received(:new) end end + + describe "#identifier_suggestion_data" do + subject(:rendered) { render_inline(described_class.new(project:)) } + + it "mounts the Stimulus controller on the wrapper" do + expect(rendered).to have_css("[data-controller='projects--identifier-suggestion']") + end + + it "includes the suggestion URL" do + expect(rendered).to have_css("[data-projects--identifier-suggestion-url-value='/projects/identifier_suggestion']") + end + + it "includes the set_name_first translation" do + expect(rendered).to have_css( + "[data-projects--identifier-suggestion-set-name-first-value='#{I18n.t("js.projects.identifier_suggestion.set_name_first")}']" + ) + end + + context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do + it "sets mode to semantic" do + expect(rendered).to have_css("[data-projects--identifier-suggestion-mode-value='semantic']") + end + end + + context "with numeric identifiers", with_settings: { work_packages_identifier: "numeric" } do + it "sets mode to legacy" do + expect(rendered).to have_css("[data-projects--identifier-suggestion-mode-value='legacy']") + end + end + end end diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index 7d1172f0b95..d6f2550f634 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -560,6 +560,45 @@ RSpec.describe "Projects", "creation", end end + context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do + it "auto-suggests an identifier when the name field is blurred" do + projects_page.create_new_workspace + click_on "Continue" + + fill_in "Name", with: "Flight Planning Algorithm" + find("body").click # blur the name field + + expect(page).to have_field "Identifier", with: "FPA" + end + + it "allows overriding the auto-suggested identifier" do + projects_page.create_new_workspace + click_on "Continue" + + fill_in "Name", with: "Flight Planning Algorithm" + find("body").click + + fill_in "Identifier", with: "MYIDENT" + click_on "Complete" + + expect_and_dismiss_flash type: :success, message: "Successful creation." + expect(page).to have_current_path %r{/projects/MYIDENT/?} + end + + it "shows a validation error for identifiers not starting with a letter" do + projects_page.create_new_workspace + click_on "Continue" + + fill_in "Name", with: "Flight Planning Algorithm" + find("body").click + + fill_in "Identifier", with: "3INVALID" + click_on "Complete" + + expect(page).to have_text "Identifier must start with a letter" + end + end + context "with workspace type badges in parent field", with_flag: { portfolio_models: true } do include_context "ng-select-autocomplete helpers" diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 89d8daf1017..03405df2ae4 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -643,4 +643,44 @@ RSpec.describe Project do end end end + + describe ".suggest_identifier" do + context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do + it "returns initials for multi-word names" do + expect(described_class.suggest_identifier("Flight Planning Algorithm")).to eq("FPA") + end + + it "returns the first 3 characters for single-word names" do + expect(described_class.suggest_identifier("Banana")).to eq("BAN") + end + + it "returns an identifier starting with a letter even for digit-prefixed names" do + expect(described_class.suggest_identifier("3D Printing Lab")).to match(/\A[A-Z]/) + end + + it "transliterates accented characters" do + result = described_class.suggest_identifier("Équipe Réseau") + expect(result).to match(/\A[A-Z][A-Z0-9_]*\z/) + end + + it "falls back to PROJ for non-transliterable names" do + expect(described_class.suggest_identifier("日本語プロジェクト")).to eq("PROJ") + end + end + + context "with numeric (legacy) identifiers", with_settings: { work_packages_identifier: "numeric" } do + it "returns a slugified lowercase identifier" do + expect(described_class.suggest_identifier("My Cool Project")).to eq("my-cool-project") + end + + it "truncates to IDENTIFIER_MAX_LENGTH" do + long_name = "a" * 300 + expect(described_class.suggest_identifier(long_name).length).to be <= described_class::IDENTIFIER_MAX_LENGTH + end + + it "falls back to 'project' for blank names" do + expect(described_class.suggest_identifier("")).to eq("project") + end + end + end end diff --git a/spec/requests/projects/identifier_suggestions_spec.rb b/spec/requests/projects/identifier_suggestions_spec.rb index 869839e8b83..9af753ee399 100644 --- a/spec/requests/projects/identifier_suggestions_spec.rb +++ b/spec/requests/projects/identifier_suggestions_spec.rb @@ -40,6 +40,24 @@ RSpec.describe "GET /projects/identifier_suggestion", type: :rails_request do expect(response.parsed_body["identifier"]).to eq("FPA") end + it "returns a single-word suggestion for single-word names" do + get "/projects/identifier_suggestion", params: { name: "Banana" }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["identifier"]).to eq("BAN") + end + + it "returns an identifier starting with a letter for digit-prefixed names" do + get "/projects/identifier_suggestion", params: { name: "3D Printing Lab" }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["identifier"]).to match(/\A[A-Z]/) + end + + it "transliterates accented characters" do + get "/projects/identifier_suggestion", params: { name: "Équipe Réseau" }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["identifier"]).to match(/\A[A-Z][A-Z0-9_]*\z/) + end + it "returns 422 when name is blank" do get "/projects/identifier_suggestion", params: { name: "" }, as: :json expect(response).to have_http_status(:unprocessable_entity) @@ -54,4 +72,17 @@ RSpec.describe "GET /projects/identifier_suggestion", type: :rails_request do end end end + + context "with numeric (legacy) identifiers", with_settings: { work_packages_identifier: "numeric" } do + it "returns a slugified lowercase identifier" do + get "/projects/identifier_suggestion", params: { name: "My Cool Project" }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["identifier"]).to eq("my-cool-project") + end + + it "returns 422 when name is blank" do + get "/projects/identifier_suggestion", params: { name: "" }, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end end From 4bbd4ffdf8e832596a2ac73baeabec7a51dae4c8 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:18:40 +0100 Subject: [PATCH 190/435] fix a feature test --- spec/features/projects/edit_settings_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index d5a27d87b23..2a6f1a68e0e 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -116,7 +116,7 @@ RSpec.describe "Projects", "editing settings", :js do fill_in "project[identifier]", with: "FOO@BAR" click_on "Change identifier" - expect(page).to have_text "Special characters not allowed." + expect(page).to have_text "Identifier may only contain uppercase letters, numbers, and underscores" end end end From b5e4f3a886bba8072d8cf9a59827ec7cb5ccc991 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:22:43 +0100 Subject: [PATCH 191/435] lint --- spec/components/projects/new_component_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/components/projects/new_component_spec.rb b/spec/components/projects/new_component_spec.rb index 0fb1a4e946a..af68052434b 100644 --- a/spec/components/projects/new_component_spec.rb +++ b/spec/components/projects/new_component_spec.rb @@ -94,8 +94,9 @@ RSpec.describe Projects::NewComponent, type: :component do end it "includes the set_name_first translation" do + translation = I18n.t('js.projects.identifier_suggestion.set_name_first') expect(rendered).to have_css( - "[data-projects--identifier-suggestion-set-name-first-value='#{I18n.t("js.projects.identifier_suggestion.set_name_first")}']" + "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" ) end From b2055c2b8e8c9f9fd259bb067c09b98730c5bb7c Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:43:51 +0100 Subject: [PATCH 192/435] fix the test that includes delay --- spec/components/projects/new_component_spec.rb | 2 +- spec/features/projects/create_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/components/projects/new_component_spec.rb b/spec/components/projects/new_component_spec.rb index af68052434b..b43e6df0ec4 100644 --- a/spec/components/projects/new_component_spec.rb +++ b/spec/components/projects/new_component_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Projects::NewComponent, type: :component do end it "includes the set_name_first translation" do - translation = I18n.t('js.projects.identifier_suggestion.set_name_first') + translation = I18n.t("js.projects.identifier_suggestion.set_name_first") expect(rendered).to have_css( "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" ) diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index d6f2550f634..4b7fb8155cb 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -577,6 +577,7 @@ RSpec.describe "Projects", "creation", fill_in "Name", with: "Flight Planning Algorithm" find("body").click + expect(page).to have_field "Identifier", with: "FPA" fill_in "Identifier", with: "MYIDENT" click_on "Complete" From 8f1f56db9042c86e8bed544646d6d2b7e379cdc9 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:48:49 +0100 Subject: [PATCH 193/435] Revert "EditableIdentifierForm => IdentifierForm" This reverts commit b1fff98b3aa80bdaf75f83126913a101b8fb65a1. --- .../projects/new_component.html.erb | 2 +- ...hange_identifier_dialog_component.html.erb | 2 +- .../settings/editable_identifier_form.rb | 60 +++++++++++++++++++ .../projects/settings/identifier_form.rb | 34 ++++------- lib_static/open_project/feature_decisions.rb | 4 -- ...ec.rb => editable_identifier_form_spec.rb} | 2 +- 6 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 app/forms/projects/settings/editable_identifier_form.rb rename spec/forms/projects/settings/{identifier_form_spec.rb => editable_identifier_form_spec.rb} (97%) diff --git a/app/components/projects/new_component.html.erb b/app/components/projects/new_component.html.erb index db53271dd76..af5304489ba 100644 --- a/app/components/projects/new_component.html.erb +++ b/app/components/projects/new_component.html.erb @@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details. render( Primer::Forms::FormList.new( Projects::Settings::NameForm.new(f), - Projects::Settings::IdentifierForm.new(f), + Projects::Settings::EditableIdentifierForm.new(f), Projects::Settings::DescriptionForm.new(f), Projects::Settings::RelationsForm.new(f, invisible: params[:parent_id].present?), Projects::Settings::TypeForm.new(f) diff --git a/app/components/projects/settings/change_identifier_dialog_component.html.erb b/app/components/projects/settings/change_identifier_dialog_component.html.erb index 5a139ceadd2..1a7ac53fdde 100644 --- a/app/components/projects/settings/change_identifier_dialog_component.html.erb +++ b/app/components/projects/settings/change_identifier_dialog_component.html.erb @@ -40,7 +40,7 @@ See COPYRIGHT and LICENSE files for more details. url: project_identifier_path(project), id: "change-identifier-form", mt: 4) do |f| %> - <%= render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f))) %> + <%= render(Primer::Forms::FormList.new(Projects::Settings::EditableIdentifierForm.new(f))) %> <% end %> <% end %> diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb new file mode 100644 index 00000000000..f929e1bd6f0 --- /dev/null +++ b/app/forms/projects/settings/editable_identifier_form.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Projects + module Settings + class EditableIdentifierForm < ApplicationForm + form do |f| + if Setting::WorkPackageIdentifier.alphanumeric? + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), + required: true, + validation_message: validation_message_for(:identifier) + ) + else + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), + required: true, + validation_message: validation_message_for(:identifier) + ) + end + end + + private + + def validation_message_for(attribute) + model.errors.full_messages_for(attribute).to_sentence.presence + end + end + end +end diff --git a/app/forms/projects/settings/identifier_form.rb b/app/forms/projects/settings/identifier_form.rb index 259bb662979..872e8de891d 100644 --- a/app/forms/projects/settings/identifier_form.rb +++ b/app/forms/projects/settings/identifier_form.rb @@ -31,29 +31,17 @@ module Projects module Settings class IdentifierForm < ApplicationForm form do |f| - if Setting::WorkPackageIdentifier.alphanumeric? - f.text_field( - name: :identifier, - label: attribute_name(:identifier), - caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), - required: true, - validation_message: validation_message_for(:identifier) - ) - else - f.text_field( - name: :identifier, - label: attribute_name(:identifier), - caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), - required: true, - validation_message: validation_message_for(:identifier) - ) - end - end - - private - - def validation_message_for(attribute) - model.errors.full_messages_for(attribute).to_sentence.presence + caption_key = if Setting::WorkPackageIdentifier.alphanumeric? + :text_project_identifier_description + else + :text_project_identifier_url_description + end + f.text_field( + name: :identifier, + label: attribute_name(:identifier), + caption: I18n.t(caption_key), + disabled: true + ) end end end diff --git a/lib_static/open_project/feature_decisions.rb b/lib_static/open_project/feature_decisions.rb index cb2f06b09cc..8a30ac6fb46 100644 --- a/lib_static/open_project/feature_decisions.rb +++ b/lib_static/open_project/feature_decisions.rb @@ -112,9 +112,5 @@ module OpenProject writable: !force_active, disallow_override: force_active end - - def self.semantic_work_package_ids_active? - # code here - end end end diff --git a/spec/forms/projects/settings/identifier_form_spec.rb b/spec/forms/projects/settings/editable_identifier_form_spec.rb similarity index 97% rename from spec/forms/projects/settings/identifier_form_spec.rb rename to spec/forms/projects/settings/editable_identifier_form_spec.rb index 20e614cdc16..45cdce0f11f 100644 --- a/spec/forms/projects/settings/identifier_form_spec.rb +++ b/spec/forms/projects/settings/editable_identifier_form_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe Projects::Settings::IdentifierForm, type: :forms do +RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do include_context "with rendered form" let(:model) { build_stubbed(:project, identifier: "my-project") } From 9147b8508d51ccf58a1d4f82640d5cebd3a34941 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:56:20 +0100 Subject: [PATCH 194/435] remove the extra copy of identifier_suggestion_data --- .../projects/concerns/identifier_suggestion.rb | 11 ++++++----- app/components/projects/new_component.rb | 11 ----------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/app/components/projects/concerns/identifier_suggestion.rb b/app/components/projects/concerns/identifier_suggestion.rb index c768d332ce5..2c970953180 100644 --- a/app/components/projects/concerns/identifier_suggestion.rb +++ b/app/components/projects/concerns/identifier_suggestion.rb @@ -32,13 +32,14 @@ module Projects module Concerns module IdentifierSuggestion def identifier_suggestion_data - flag_active = OpenProject::FeatureDecisions.semantic_work_package_ids_active? - data = { + suggestion_mode = Setting::WorkPackageIdentifier.alphanumeric? ? "semantic" : "legacy" + + { controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": Project.semantic_alphanumeric_identifier? ? "semantic" : "legacy" + "projects--identifier-suggestion-mode-value": suggestion_mode, + "projects--identifier-suggestion-url-value": projects_identifier_suggestion_path, + "projects--identifier-suggestion-set-name-first-value": I18n.t("js.projects.identifier_suggestion.set_name_first") } - data[:"projects--identifier-suggestion-url-value"] = projects_identifier_suggestion_path if flag_active - data end end end diff --git a/app/components/projects/new_component.rb b/app/components/projects/new_component.rb index d81f75a24c8..ffe2faeae9d 100644 --- a/app/components/projects/new_component.rb +++ b/app/components/projects/new_component.rb @@ -45,17 +45,6 @@ module Projects { display: :none } unless step == 3 end - def identifier_suggestion_data - suggestion_mode = Setting::WorkPackageIdentifier.alphanumeric? ? "semantic" : "legacy" - - { - controller: "projects--identifier-suggestion", - "projects--identifier-suggestion-mode-value": suggestion_mode, - "projects--identifier-suggestion-url-value": projects_identifier_suggestion_path, - "projects--identifier-suggestion-set-name-first-value": I18n.t("js.projects.identifier_suggestion.set_name_first") - } - end - def workspaces_path workspace_type = if Project.workspace_types.key?(project.workspace_type) project.workspace_type From 1f378939eec56c334e82a2895932843e8ab8612d Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 22:57:51 +0100 Subject: [PATCH 195/435] add a test --- .../projects/copy_form_component_spec.rb | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/spec/components/projects/copy_form_component_spec.rb b/spec/components/projects/copy_form_component_spec.rb index b1933497ebc..0f0bcf0f6fd 100644 --- a/spec/components/projects/copy_form_component_spec.rb +++ b/spec/components/projects/copy_form_component_spec.rb @@ -42,4 +42,35 @@ RSpec.describe Projects::CopyFormComponent, type: :component do it "renders a form" do expect(render_component).to have_css "form" end + + describe "#identifier_suggestion_data" do + it "mounts the Stimulus controller on the wrapper" do + expect(render_component).to have_css("[data-controller='projects--identifier-suggestion']") + end + + it "includes the suggestion URL" do + expect(render_component).to have_css( + "[data-projects--identifier-suggestion-url-value='/projects/identifier_suggestion']" + ) + end + + it "includes the set_name_first translation" do + translation = I18n.t('js.projects.identifier_suggestion.set_name_first') + expect(render_component).to have_css( + "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" + ) + end + + context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do + it "sets mode to semantic" do + expect(render_component).to have_css("[data-projects--identifier-suggestion-mode-value='semantic']") + end + end + + context "with numeric identifiers", with_settings: { work_packages_identifier: "numeric" } do + it "sets mode to legacy" do + expect(render_component).to have_css("[data-projects--identifier-suggestion-mode-value='legacy']") + end + end + end end From d3ac57c100d7df0ca838b37de1de76aca2d3d47e Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 16 Mar 2026 23:11:43 +0100 Subject: [PATCH 196/435] rubocop bit --- spec/components/projects/copy_form_component_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/components/projects/copy_form_component_spec.rb b/spec/components/projects/copy_form_component_spec.rb index 0f0bcf0f6fd..f545f35bc26 100644 --- a/spec/components/projects/copy_form_component_spec.rb +++ b/spec/components/projects/copy_form_component_spec.rb @@ -55,7 +55,7 @@ RSpec.describe Projects::CopyFormComponent, type: :component do end it "includes the set_name_first translation" do - translation = I18n.t('js.projects.identifier_suggestion.set_name_first') + translation = I18n.t("js.projects.identifier_suggestion.set_name_first") expect(render_component).to have_css( "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" ) From e3cbf3350042b690139bde370870bc19cdc0c6cb Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 16 Mar 2026 17:50:26 -0700 Subject: [PATCH 197/435] Split sprint start/finish feature specs Move the sprint lifecycle feature coverage out of the edit spec and into a dedicated start/finish spec. This keeps edit_spec focused on sprint editing and menu behavior. https://github.com/opf/openproject/pull/22086 --- .../spec/features/sprints/edit_spec.rb | 42 ------ .../features/sprints/start_finish_spec.rb | 121 ++++++++++++++++++ 2 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 modules/backlogs/spec/features/sprints/start_finish_spec.rb diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb index 8e3d7211a6d..6eb4be766e0 100644 --- a/modules/backlogs/spec/features/sprints/edit_spec.rb +++ b/modules/backlogs/spec/features/sprints/edit_spec.rb @@ -150,16 +150,6 @@ RSpec.describe "Edit", :js do end end - it "starts the sprint and redirects to the board" do - backlogs_page.click_in_sprint_menu(first_sprint, "Start sprint") - - expect_and_dismiss_flash type: :success, message: "The sprint was started." - - expect(page).to have_current_path(%r{/projects/#{project.identifier}/boards/\d+}) - expect(first_sprint.reload.task_board).to be_present - expect(first_sprint.reload).to be_active - end - it "edits the sprint name" do backlogs_page.expect_sprint_names_in_order(first_sprint.name, second_sprint.name) @@ -175,38 +165,6 @@ RSpec.describe "Edit", :js do backlogs_page.expect_sprint_names_in_order("Changed name", second_sprint.name) end - context "when the sprint is active" do - let!(:first_sprint) do - create(:agile_sprint, - project:, - status: "active", - start_date: Date.new(2025, 9, 5), - finish_date: Date.new(2025, 9, 15)) - end - - let!(:second_sprint) do - create(:agile_sprint, - project:, - start_date: Date.new(2025, 9, 16), - finish_date: Date.new(2025, 9, 26)) - end - - let!(:task_board) { create(:board_grid_with_query, project:, linked: first_sprint) } - - it "finishes the sprint and returns to the backlog" do - backlogs_page.within_sprint_menu(first_sprint) do |menu| - expect(menu).to have_selector :menuitem, "Finish sprint" - expect(menu).to have_css "form[action='#{finish_project_sprint_path(project, first_sprint)}'][data-turbo='false']" - menu.find(:button, "Finish sprint").click - end - - backlogs_page.expect_current_path - expect_and_dismiss_flash type: :success, message: "The sprint was completed." - expect(first_sprint.reload).to be_completed - backlogs_page.expect_sprint_names_in_order(second_sprint.name) - end - end - context "when lacking the 'manage_sprint_items' permission" do let(:permissions) { all_permissions - %i[manage_sprint_items] } diff --git a/modules/backlogs/spec/features/sprints/start_finish_spec.rb b/modules/backlogs/spec/features/sprints/start_finish_spec.rb new file mode 100644 index 00000000000..f840eb39fc3 --- /dev/null +++ b/modules/backlogs/spec/features/sprints/start_finish_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../../support/pages/backlogs" + +RSpec.describe "Start and finish sprints", :js, with_flag: { scrum_projects: true } do + let(:project) { create(:project) } + let(:permissions) do + %i[view_sprints add_work_packages view_work_packages create_sprints manage_sprint_items + start_complete_sprint show_board_views manage_board_views save_queries + manage_public_queries] + end + let(:user) do + create(:user, member_with_permissions: { project => permissions }) + end + let(:backlogs_page) { Pages::Backlogs.new(project) } + let(:story_type) { create(:type_feature) } + let(:task_type) do + type = create(:type_task) + project.types << type + + type + end + let!(:first_sprint) do + create(:agile_sprint, + project:, + start_date: Date.new(2025, 9, 5), + finish_date: Date.new(2025, 9, 15)) + end + let!(:second_sprint) do + create(:agile_sprint, + project:, + start_date: Date.new(2025, 9, 16), + finish_date: Date.new(2025, 9, 26)) + end + let!(:closed_sprint) do + create(:agile_sprint, + project:, + status: "completed", + start_date: Date.new(2025, 8, 25), + finish_date: Date.new(2025, 9, 4)) + end + + # Necessary so that work packages can be created via dialog + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + + before do + login_as(user) + + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [story_type.id.to_s], "task_type" => task_type.id.to_s) + + create(:workflow, type: task_type, old_status: default_status, new_status: default_status, role: create(:project_role)) + + backlogs_page.visit! + end + + it "starts the sprint and redirects to the board" do + backlogs_page.click_in_sprint_menu(first_sprint, "Start sprint") + + expect_and_dismiss_flash type: :success, message: "The sprint was started." + + expect(page).to have_current_path(%r{/projects/#{project.identifier}/boards/\d+}) + expect(first_sprint.reload.task_board).to be_present + expect(first_sprint.reload).to be_active + end + + context "when the sprint is active" do + let!(:first_sprint) do + create(:agile_sprint, + project:, + status: "active", + start_date: Date.new(2025, 9, 5), + finish_date: Date.new(2025, 9, 15)) + end + let!(:task_board) { create(:board_grid_with_query, project:, linked: first_sprint) } + + it "finishes the sprint and returns to the backlog" do + backlogs_page.within_sprint_menu(first_sprint) do |menu| + expect(menu).to have_selector :menuitem, "Finish sprint" + expect(menu).to have_css "form[action='#{finish_project_sprint_path(project, first_sprint)}'][data-turbo='false']" + menu.find(:button, "Finish sprint").click + end + + backlogs_page.expect_current_path + expect_and_dismiss_flash type: :success, message: "The sprint was completed." + expect(first_sprint.reload).to be_completed + backlogs_page.expect_sprint_names_in_order(second_sprint.name) + end + end +end From 772fae721bcefa36320476175e8126f538a22999 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 16 Mar 2026 18:04:22 -0700 Subject: [PATCH 198/435] Flesh out sprint start feature spec --- .../features/sprints/start_finish_spec.rb | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/spec/features/sprints/start_finish_spec.rb b/modules/backlogs/spec/features/sprints/start_finish_spec.rb index f840eb39fc3..a71ff63dda6 100644 --- a/modules/backlogs/spec/features/sprints/start_finish_spec.rb +++ b/modules/backlogs/spec/features/sprints/start_finish_spec.rb @@ -30,9 +30,15 @@ require "spec_helper" require_relative "../../support/pages/backlogs" +require_relative "../../../../boards/spec/features/support/board_page" -RSpec.describe "Start and finish sprints", :js, with_flag: { scrum_projects: true } do - let(:project) { create(:project) } +RSpec.describe "Start and finish sprints", + :js, + with_ee: %i[board_view], + with_flag: { scrum_projects: true } do + let(:project) do + create(:project, enabled_module_names: %i[backlogs work_package_tracking board_view]) + end let(:permissions) do %i[view_sprints add_work_packages view_work_packages create_sprints manage_sprint_items start_complete_sprint show_board_views manage_board_views save_queries @@ -42,6 +48,7 @@ RSpec.describe "Start and finish sprints", :js, with_flag: { scrum_projects: tru create(:user, member_with_permissions: { project => permissions }) end let(:backlogs_page) { Pages::Backlogs.new(project) } + let(:task_statuses) { Type.find(Task.type).statuses } let(:story_type) { create(:type_feature) } let(:task_type) do type = create(:type_task) @@ -90,9 +97,40 @@ RSpec.describe "Start and finish sprints", :js, with_flag: { scrum_projects: tru expect_and_dismiss_flash type: :success, message: "The sprint was started." + sprint = first_sprint.reload + board = sprint.task_board + board_page = Pages::Board.new(board) + expect(page).to have_current_path(%r{/projects/#{project.identifier}/boards/\d+}) - expect(first_sprint.reload.task_board).to be_present - expect(first_sprint.reload).to be_active + expect(sprint).to be_active + expect(board).to be_present + + board_page.expect_path + task_statuses.each do |status| + board_page.expect_list(status.name) + end + + board_page.board(reload: true) do |persisted_board| + expect(persisted_board.linked).to eq(sprint) + expect(persisted_board.options[:type]).to eq("action") + expect(persisted_board.options[:attribute]).to eq("status") + expect(persisted_board.options[:filters]).to eq( + [{ sprint_id: { operator: "=", values: [sprint.id.to_s] } }] + ) + + queries = persisted_board.contained_queries.to_a + expect(queries.count).to eq(task_statuses.count) + + query_status_values = queries.map do |query| + status_filter = query.filters.find { |filter| filter.name == :status_id } + + expect(status_filter).not_to be_nil + + status_filter.values + end + + expect(query_status_values).to match_array(task_statuses.map { |status| [status.id.to_s] }) + end end context "when the sprint is active" do From 3aefd899defe044b70c0bdd1a02a716e4c45960b Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 17 Mar 2026 09:30:49 +0100 Subject: [PATCH 199/435] Do not leak detail `dup` onto other STI models of user --- app/models/concerns/has_principal_details.rb | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_principal_details.rb index 6c1905c1d3f..41d2fb94b6e 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_principal_details.rb @@ -35,14 +35,6 @@ module HasPrincipalDetails # and should not be delegated to the principal. DETAIL_INTERNAL_COLUMNS = %w[id principal_id created_at updated_at].freeze - # AR's dup doesn't copy associations, so the detail would be lost. - # Duplicate it so the copy behaves like a normal AR dup with all attributes. - def dup - super.tap do |copy| - copy.detail = detail.dup if detail.present? - end - end - class_methods do # Declares a detail table for this principal subclass. # The detail model class is generated automatically — no separate file needed. @@ -66,10 +58,21 @@ module HasPrincipalDetails setup_detail_association(association_name, detail_class) setup_detail_aliases(association_name) setup_detail_delegation(detail_class) + setup_detail_dup end private + # AR's dup doesn't copy associations, so the detail would be lost. + # Duplicate it so the copy behaves like a normal AR dup with all attributes. + def setup_detail_dup + define_method(:dup) do + super().tap do |copy| + copy.detail = detail.dup if detail.present? + end + end + end + def build_detail_class(&block) owner_name = model_name.element.to_sym # e.g. :group From 9b5d8af58b9043c60c2bf63af942805d70aaa4ed Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 17 Mar 2026 09:56:38 +0100 Subject: [PATCH 200/435] Allow nesting Groups in the UI --- app/forms/groups/form.rb | 11 +++++++++++ app/models/permitted_params.rb | 9 +++++---- config/locales/en.yml | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/forms/groups/form.rb b/app/forms/groups/form.rb index c63fd9f4b24..ae1b2a672b1 100644 --- a/app/forms/groups/form.rb +++ b/app/forms/groups/form.rb @@ -40,6 +40,17 @@ module Groups autocomplete: "off" ) + f.select_list( + name: :parent_id, + label: Group.human_attribute_name(:parent), + include_blank: I18n.t(:label_no_parent_group), + input_width: :medium + ) do |list| + Group.where.not(id: model.self_and_descendants.select(:id)).each do |group| + list.option(label: group.name, value: group.id, selected: model.parent_id == group.id) + end + end + render_custom_fields(form: f) f.submit( diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 51dec7954b7..b83ae074647 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -309,7 +309,7 @@ class PermittedParams end def copy_project_options - copy_options_params = params.expect(copy_options: [[dependencies: []], :send_notifications]) + copy_options_params = params.expect(copy_options: [[{ dependencies: [] }], :send_notifications]) copy_options_params[:dependencies].compact_blank! copy_options_params end @@ -526,8 +526,9 @@ class PermittedParams name reassign_to_id ), - group: [ - :lastname + group: %i[ + lastname + parent_id ], membership: [ :project_id, @@ -541,7 +542,7 @@ class PermittedParams ] } ], member: [ - role_ids: [] + { role_ids: [] } ], new_work_package: [ :assigned_to_id, diff --git a/config/locales/en.yml b/config/locales/en.yml index 4316ddd65f4..f229efdf0e1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3845,6 +3845,7 @@ en: label_no_due_date: "no finish date" label_no_start_date: "no start date" label_no_parent_page: "No parent page" + label_no_parent_group: "(No parent group)" label_notification_center_plural: "Notifications" label_nothing_display: "Nothing to display" label_nobody: "nobody" From 9dd52bf358ffc0edbc62fdc1957e835d83a548d9 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 17 Mar 2026 10:26:38 +0100 Subject: [PATCH 201/435] Add nested group display in the UI --- app/components/groups/row_component.rb | 9 ++- app/controllers/groups_controller.rb | 2 +- app/forms/groups/form.rb | 7 ++- app/models/group.rb | 2 + app/models/groups/hierarchy.rb | 19 ++++++ config/locales/en.yml | 3 + .../components/groups/table_component_spec.rb | 20 ++++++ spec/models/group_spec.rb | 62 ++++++++++++++++--- 8 files changed, 110 insertions(+), 14 deletions(-) diff --git a/app/components/groups/row_component.rb b/app/components/groups/row_component.rb index 51d7bebcc93..eea139a63ff 100644 --- a/app/components/groups/row_component.rb +++ b/app/components/groups/row_component.rb @@ -31,7 +31,14 @@ module Groups class RowComponent < OpPrimer::BorderBoxRowComponent def name - render(Primer::Beta::Link.new(href: edit_group_path(model), font_weight: :bold)) { model.name } + depth = model.hierarchy_depth || 0 + link = render(Primer::Beta::Link.new(href: edit_group_path(model), font_weight: :bold)) { model.name } + + if depth > 0 + tag.span(style: "margin-left: #{depth * 20}px") { link } + else + link + end end def user_count diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 8ba62ac7f71..676366dcb8d 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -40,7 +40,7 @@ class GroupsController < ApplicationController edit_membership add_users] def index - @groups = Group.order(Arel.sql("lastname ASC")) + @groups = Group.in_tree_order end def show diff --git a/app/forms/groups/form.rb b/app/forms/groups/form.rb index ae1b2a672b1..b18a50f36b3 100644 --- a/app/forms/groups/form.rb +++ b/app/forms/groups/form.rb @@ -44,10 +44,13 @@ module Groups name: :parent_id, label: Group.human_attribute_name(:parent), include_blank: I18n.t(:label_no_parent_group), + caption: I18n.t(:label_parent_group_caption), input_width: :medium ) do |list| - Group.where.not(id: model.self_and_descendants.select(:id)).each do |group| - list.option(label: group.name, value: group.id, selected: model.parent_id == group.id) + excluded_ids = model.self_and_descendants.pluck(:id).to_set + Group.in_tree_order.reject { |g| excluded_ids.include?(g.id) }.each do |group| + prefix = "\u00A0\u00A0" * (group.hierarchy_depth || 0) + list.option(label: "#{prefix}#{group.name}", value: group.id, selected: model.parent_id == group.id) end end diff --git a/app/models/group.rb b/app/models/group.rb index 89c36de8cb3..07ac814278b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -32,6 +32,8 @@ class Group < Principal include ::Scopes::Scoped include Groups::Hierarchy + attr_accessor :hierarchy_depth + has_principal_details do belongs_to :parent, class_name: "Group", optional: true diff --git a/app/models/groups/hierarchy.rb b/app/models/groups/hierarchy.rb index 2de7aaca63b..17dc857efa6 100644 --- a/app/models/groups/hierarchy.rb +++ b/app/models/groups/hierarchy.rb @@ -69,6 +69,25 @@ module Groups::Hierarchy parent_id.nil? end + class_methods do + # Returns all groups in depth-first tree order, alphabetical within each level. + # Each group has its `hierarchy_depth` set to its nesting level (0 for roots). + def in_tree_order + all_groups = with_detail.order(Arel.sql("lastname ASC")).to_a + children_by_parent = all_groups.group_by(&:parent_id) + walk_tree(children_by_parent, nil, 0) + end + + private + + def walk_tree(children_by_parent, parent_id, depth) + (children_by_parent[parent_id] || []).flat_map do |group| + group.hierarchy_depth = depth + [group, *walk_tree(children_by_parent, group.id, depth + 1)] + end + end + end + private def descendant_ids diff --git a/config/locales/en.yml b/config/locales/en.yml index f229efdf0e1..ef755505ede 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3874,6 +3874,9 @@ en: label_overall_activity: "Overall activity" label_overview: "Overview" label_page_title: "Page title" + label_parent_group_caption: > + Setting a parent group will make this group a subgroup of the selected parent group. + This will also inherit all memberships, including permissions of the parent group. label_part_of: "part of" label_password_lost: "Forgot your password?" label_password_rule_lowercase: "Lowercase" diff --git a/spec/components/groups/table_component_spec.rb b/spec/components/groups/table_component_spec.rb index 834186bde3f..5376e197e70 100644 --- a/spec/components/groups/table_component_spec.rb +++ b/spec/components/groups/table_component_spec.rb @@ -61,4 +61,24 @@ RSpec.describe Groups::TableComponent, type: :component do it_behaves_like "rendering Border Box Grid headings" it_behaves_like "rendering Border Box Grid rows", row_count: 2, col_count: 3 end + + context "with nested groups" do + let(:parent_group) do + create(:group, lastname: "Parent").tap { |g| g.hierarchy_depth = 0 } + end + let(:child_group) do + create(:group, lastname: "Child", parent: parent_group).tap { |g| g.hierarchy_depth = 1 } + end + let(:groups) { [parent_group, child_group] } + + it "renders child groups with indentation and arrow icon" do + expect(rendered_component).to have_css("span[style='margin-left: 20px']") + expect(rendered_component).to have_css("span[style='margin-left: 20px'] a", text: "Child") + end + + it "renders parent groups without indentation" do + expect(rendered_component).to have_link("Parent") + expect(rendered_component).to have_no_css("span[style='margin-left: 0px']") + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 7b9e6c1dc13..5991ba21846 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -78,7 +78,7 @@ RSpec.describe Group do context "if it does not exist" do it "does not create a group user" do count = group.group_users.count - gu = group.group_users.create user_id: User.maximum(:id).to_i + 1 + gu = group.group_users.create! user_id: User.maximum(:id).to_i + 1 expect(gu).not_to be_valid expect(group.group_users.count).to eq count @@ -87,7 +87,7 @@ RSpec.describe Group do it "updates the timestamp" do updated_at = group.updated_at - group.group_users.create(user:) + group.group_users.create!(user:) expect(updated_at < group.reload.updated_at) .to be_truthy @@ -96,7 +96,7 @@ RSpec.describe Group do context "when removing a user" do it "updates the timestamp" do - group.group_users.create(user:) + group.group_users.create!(user:) updated_at = group.reload.updated_at group.group_users.destroy_all @@ -149,12 +149,12 @@ RSpec.describe Group do before do # Add user1 to group1 and group2 - group1.group_users.create(user: user1) - group2.group_users.create(user: user1) + group1.group_users.create!(user: user1) + group2.group_users.create!(user: user1) # Add user2 to group2 and group3 - group2.group_users.create(user: user2) - group3.group_users.create(user: user2) + group2.group_users.create!(user: user2) + group3.group_users.create!(user: user2) end it "returns groups that contain the given user" do @@ -185,11 +185,11 @@ RSpec.describe Group do before do # target_user is in both groups - shared_group.group_users.create(user: target_user) - other_group.group_users.create(user: target_user) + shared_group.group_users.create!(user: target_user) + other_group.group_users.create!(user: target_user) # viewer is only in shared_group - shared_group.group_users.create(user: viewer) + shared_group.group_users.create!(user: viewer) end it "returns only the groups the viewer can see from the user's groups" do @@ -283,6 +283,48 @@ RSpec.describe Group do end end + describe ".in_tree_order" do + it "returns groups in depth-first order, alphabetical within each level" do + result = described_class.in_tree_order + + grandparent_idx = result.index(grandparent) + parent_idx = result.index(parent_group) + child_idx = result.index(child) + grandchild_idx = result.index(grandchild) + + expect(grandparent_idx).to be < parent_idx + expect(parent_idx).to be < child_idx + expect(child_idx).to be < grandchild_idx + end + + it "sets hierarchy_depth on each group" do + result = described_class.in_tree_order + depths = result.to_h { |g| [g.id, g.hierarchy_depth] } + + expect(depths[grandparent.id]).to eq(0) + expect(depths[parent_group.id]).to eq(1) + expect(depths[child.id]).to eq(2) + expect(depths[grandchild.id]).to eq(3) + expect(depths[unrelated.id]).to eq(0) + end + + it "includes all groups" do + result = described_class.in_tree_order + expect(result).to contain_exactly(grandparent, parent_group, child, grandchild, unrelated) + end + + it "sorts siblings alphabetically" do + sibling_a = create(:group, lastname: "AAA Sibling", parent_id: grandparent.id) + sibling_z = create(:group, lastname: "ZZZ Sibling", parent_id: grandparent.id) + + result = described_class.in_tree_order + sibling_a_idx = result.index(sibling_a) + sibling_z_idx = result.index(sibling_z) + + expect(sibling_a_idx).to be < sibling_z_idx + end + end + describe "circular dependency prevention" do it "is invalid when assigning self as parent" do grandparent.parent_id = grandparent.id From 56ee2395a287c57584915af9c1e628faf0f283d1 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 17 Mar 2026 10:45:52 +0100 Subject: [PATCH 202/435] Allow setting new group fields in Groups::BaseContract --- app/contracts/groups/base_contract.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/contracts/groups/base_contract.rb b/app/contracts/groups/base_contract.rb index 16a0970a191..9b222968645 100644 --- a/app/contracts/groups/base_contract.rb +++ b/app/contracts/groups/base_contract.rb @@ -37,6 +37,8 @@ module Groups # hence we need to put "lastname" as an attribute here attribute :name attribute :lastname + attribute :parent_id + attribute :organizational_unit validate :validate_unique_users From dd317632b8ae91eb49d185e3ee40c5a02b13edce Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Tue, 17 Mar 2026 11:10:00 +0100 Subject: [PATCH 203/435] [fix] rubocop issues in generated files --- modules/wikis/lib/open_project/wikis/engine.rb | 7 +++---- modules/wikis/openproject-wikis.gemspec | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb index 1d25f2c04bf..c01586c0670 100644 --- a/modules/wikis/lib/open_project/wikis/engine.rb +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Prevent load-order problems in case openproject-plugins is listed after a plugin in the Gemfile +# Prevent load-order problems in case openproject-plugins is listed after a plugin in the Gemfile # or not at all require "open_project/plugins" @@ -39,8 +39,7 @@ module OpenProject::Wikis include OpenProject::Plugins::ActsAsOpEngine register "openproject-wikis", - :author_url => "https://openproject.org", - :requires_openproject => ">= 17.0.0" - + author_url: "https://openproject.org", + requires_openproject: ">= 17.0.0" end end diff --git a/modules/wikis/openproject-wikis.gemspec b/modules/wikis/openproject-wikis.gemspec index 2e12010266d..f6ef7db7d07 100644 --- a/modules/wikis/openproject-wikis.gemspec +++ b/modules/wikis/openproject-wikis.gemspec @@ -35,10 +35,10 @@ Gem::Specification.new do |s| s.authors = "OpenProject GmbH" s.email = "info@openproject.org" - s.homepage = "https://community.openproject.org/projects/wikis" # TODO check this URL s.summary = "OpenProject Wikis" s.description = "Allows linking work packages to pages in wikis, such as XWiki or the internal OpenProject wiki." s.license = "GPLv3" s.files = Dir["{app,config,db,lib}/**/*"] + %w(CHANGELOG.md README.md) + s.metadata["rubygems_mfa_required"] = "true" end From 06bae92e262544bff7df764dec141b17ff1bcde7 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 16 Mar 2026 12:31:15 +0000 Subject: [PATCH 204/435] patch mail gem to support tls_hostname until it is officially supported --- lib/open_project/patches/mail_smtp.rb | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 lib/open_project/patches/mail_smtp.rb diff --git a/lib/open_project/patches/mail_smtp.rb b/lib/open_project/patches/mail_smtp.rb new file mode 100644 index 00000000000..0624129ee22 --- /dev/null +++ b/lib/open_project/patches/mail_smtp.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "mail" + +## +# Correctly pass tls_hostname from Rails until [1] is resolved and the gem updated. +# +# [1] https://github.com/mikel/mail/issues/1660 +module OpenProject::Patches + module Mail + module SMTP + private + + def build_smtp_session + super.tap do |smtp| + smtp.tls_hostname = settings[:tls_hostname] if settings[:tls_hostname] + end + end + end + end +end + +OpenProject::Patches.patch_gem_version "mail", "2.9.0" do + Mail::SMTP.prepend OpenProject::Patches::Mail::SMTP +end From c850bf5a17b9715f6fa213884db03a5532bf847e Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 16 Mar 2026 12:31:41 +0000 Subject: [PATCH 205/435] delete patch made obsolete with mail 2.9.0 see https://github.com/mikel/mail/issues/1434 --- .../patches/mail_smtp_start_tls_auto_fix.rb | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 lib/open_project/patches/mail_smtp_start_tls_auto_fix.rb diff --git a/lib/open_project/patches/mail_smtp_start_tls_auto_fix.rb b/lib/open_project/patches/mail_smtp_start_tls_auto_fix.rb deleted file mode 100644 index b747469035f..00000000000 --- a/lib/open_project/patches/mail_smtp_start_tls_auto_fix.rb +++ /dev/null @@ -1,25 +0,0 @@ -## -# This is a fix for a new SMTP bug introduced with Ruby 3. -# This can be removed once the official fix from the `mail` gem maintainers -# has been released and the gem bumped by us. -# -# Details: https://community.openproject.org/projects/openproject/work_packages/42385/activity -module OpenProject - module Patches - module MailSmtpStartTlsAutoHotfix - def build_smtp_session - super.tap do |smtp| - smtp.disable_starttls if disable_starttls? - end - end - - def disable_starttls? - settings[:enable_starttls_auto] == false && !settings[:enable_starttls] - end - end - end -end - -require "mail" - -Mail::SMTP.prepend OpenProject::Patches::MailSmtpStartTlsAutoHotfix From b899b02f510d9201a9e4da8f282710255350304b Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Tue, 17 Mar 2026 11:27:55 +0000 Subject: [PATCH 206/435] update openproject-token to 8.8.2 including legacy + basic/professional bugfix --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a5b0d3ce8bf..f207b5df02b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -733,7 +733,7 @@ GEM jmespath (1.6.2) job-iteration (1.12.0) activejob (>= 6.1) - json (2.18.1) + json (2.19.1) json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap @@ -893,7 +893,7 @@ GEM activesupport (>= 7.2.0) openproject-octicons (>= 19.30.1) view_component (>= 3.1, < 5.0) - openproject-token (8.8.0) + openproject-token (8.8.2) activemodel openssl (4.0.0) openssl-signature_algorithm (1.3.0) @@ -1966,7 +1966,7 @@ CHECKSUMS iso8601 (0.13.0) sha256=298c2b15b7be5fa95a1372813d36a2257656cd8e906dfbc1f5cb409851425aa2 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 job-iteration (1.12.0) sha256=0164057417750f6e9c3ed548f029f1136b18eb53975fa438b09304a525d6c6c0 - json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 + json (2.19.1) sha256=dd94fdc59e48bff85913829a32350b3148156bc4fd2a95a2568a78b11344082d json-jwt (1.17.0) sha256=6ff99026b4c54281a9431179f76ceb81faa14772d710ef6169785199caadc4cc json-schema (4.3.1) sha256=d5e68dc32b94408d0b06ad04f9382ccbb6fe5a44910e066f8547f56c471a7825 json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396 @@ -2050,7 +2050,7 @@ CHECKSUMS openproject-reporting (1.0.0) openproject-storages (1.0.0) openproject-team_planner (1.0.0) - openproject-token (8.8.0) sha256=832a493e05dcce806134faf63ae8011cc5a48422fbed9ebb552f8028912954d4 + openproject-token (8.8.2) sha256=081cbff7269d92a82fa1d63e9e09c87b70d47d7aefadcbb80d1e7368bc2cf096 openproject-two_factor_authentication (1.0.0) openproject-webhooks (1.0.0) openproject-xls_export (1.0.0) From acb4a7e33218f68c7b29053bd94827b7cc1e61cc Mon Sep 17 00:00:00 2001 From: Maya Berdygylyjova Date: Tue, 17 Mar 2026 14:38:39 +0100 Subject: [PATCH 207/435] =?UTF-8?q?[#49064]=20Harmonize=20writing=20of=20"?= =?UTF-8?q?drop-down"=20vs.=20"dropdown"=20=20https://com=E2=80=A6=20(#223?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [#49064] Harmonize writing of "drop-down" vs. "dropdown" https://community.openproject.org/work_packages/49064 [#49064] Harmonize writing of "drop-down" vs. "dropdown" https://community.openproject.org/work_packages/49064 --- docs/user-guide/team-planner/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/team-planner/README.md b/docs/user-guide/team-planner/README.md index 7b63ec68a1a..519e76d2529 100644 --- a/docs/user-guide/team-planner/README.md +++ b/docs/user-guide/team-planner/README.md @@ -79,7 +79,7 @@ When you create a new team planner, it will be empty, like so: ![An example of a newly-created empty team planner in OpenProject](openproject_user_guide_teamplanner_new_unnamed_empty.png) -The first step in setting up your team planning calendar is to add team members. To do so, click on the **+ Assignee** button then search for the team member you would like to add from the the drop-down list (1). This will add a new row to the calendar view for that team member. +The first step in setting up your team planning calendar is to add team members. To do so, click on the **+ Assignee** button then search for the team member you would like to add from the the dropdown list (1). This will add a new row to the calendar view for that team member. Repeat this step until all relevant team members are added and then save it using the floppy disk icon in the top header (2). From faeb16e9fcca07ec98fbb2264f528fdc336a02b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 06:22:56 +0100 Subject: [PATCH 208/435] Bump token gem to 8.8.2 --- Gemfile | 2 +- Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index a698eb91537..dd4bb56ea2b 100644 --- a/Gemfile +++ b/Gemfile @@ -207,7 +207,7 @@ gem "aws-sdk-core", "~> 3.241" # File upload via fog + screenshots on travis gem "aws-sdk-s3", "~> 1.213" -gem "openproject-token", "~> 8.8.0" +gem "openproject-token", "~> 8.8.2" gem "plaintext", "~> 0.3.7" diff --git a/Gemfile.lock b/Gemfile.lock index f207b5df02b..44fd5a7f313 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1671,7 +1671,7 @@ DEPENDENCIES openproject-reporting! openproject-storages! openproject-team_planner! - openproject-token (~> 8.8.0) + openproject-token (~> 8.8.2) openproject-two_factor_authentication! openproject-webhooks! openproject-xls_export! From ad4549847b3fa6517ab88e8ea9da38e6fa75aab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 13:43:31 +0100 Subject: [PATCH 209/435] Fix env name SSRF by using the actual defined value --- app/controllers/admin_controller.rb | 6 ++++-- config/constants/settings/definition.rb | 12 ++++++++++-- config/locales/en.yml | 2 +- spec/controllers/admin_controller_spec.rb | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index ca726fe9136..3f3f2d6a9c6 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -111,8 +111,10 @@ class AdminController < ApplicationController @smtp_addr = ActionMailer::Base.smtp_settings[:address] @safe_ip = OpenProject::SsrfProtection.safe_ip?(@smtp_addr) - if !@safe_ip - flash[:error] = I18n.t :notice_smtp_address_unsafe, address: @smtp_addr + unless @safe_ip + flash[:error] = I18n.t :notice_smtp_address_unsafe_env_hint, + address: @smtp_addr, + env_name: Settings::Definition[:ssrf_protection_ip_allowlist].env_name redirect_to admin_settings_mail_notifications_path, status: :see_other end diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 6f18a21b143..2456c5636ae 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -1400,6 +1400,14 @@ module Settings end end + def env_name + self.class.env_name(self) + end + + def possible_env_names + self.class.possible_env_names(self) + end + def derive_default(default) @default = default.is_a?(Hash) ? default.deep_stringify_keys : default @default.freeze @@ -1689,8 +1697,6 @@ module Settings ].compact end - public :possible_env_names - def env_name_nested(definition) "#{ENV_PREFIX}#{definition.name.upcase.gsub('_', '__')}" end @@ -1709,6 +1715,8 @@ module Settings definition.env_alias.upcase end + public :possible_env_names, :env_name + ## # Extract the configuration value from the given environment variable # using YAML. diff --git a/config/locales/en.yml b/config/locales/en.yml index 25c3e9d1ca0..7f5a669513c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4388,7 +4388,7 @@ en: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/spec/controllers/admin_controller_spec.rb b/spec/controllers/admin_controller_spec.rb index 7c48017aa1b..fa567885466 100644 --- a/spec/controllers/admin_controller_spec.rb +++ b/spec/controllers/admin_controller_spec.rb @@ -143,7 +143,7 @@ RSpec.describe AdminController do it "redirects back, showing an error" do expect(response).to redirect_to admin_settings_mail_notifications_path - expect(flash[:error]).to match /OPENPROJECT_SSRF_PROTECTION_ALLOWLIST/ + expect(flash[:error]).to match /OPENPROJECT_SSRF_PROTECTION_IP_ALLOWLIST/ end end From a445cc196cb5af0f1ed9ef30eb395bf4aeac410c Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 16 Mar 2026 15:37:04 +0100 Subject: [PATCH 210/435] * Take care that newly created CF are also correctly registered * Introduce spec helper for inplace edit fields * Preserve system_arguments for calculated fields when they are updated --- .../inplace_edit_field_component.html.erb | 3 +- .../common/inplace_edit_field_component.rb | 16 +- ...place_edit_field_dialog_component.html.erb | 3 +- .../display_fields/display_field_component.rb | 15 +- .../inplace_edit_fields_controller.rb | 31 +- app/models/custom_field.rb | 5 + config/initializers/inplace_edit_fields.rb | 7 +- .../dynamic/inplace-edit.controller.ts | 34 ++ .../inplace_edit/field_registry.rb | 12 +- .../patterns/06-inplace-edit-fields.md.erb | 4 + .../project_description_widget_spec.rb | 14 +- .../display_field_component_spec.rb | 4 +- .../{dialog => }/attribute_help_texts_spec.rb | 64 ++-- .../overview_page/dialog/render_spec.rb | 31 +- .../overview_page/{dialog => }/inputs_spec.rb | 312 +++++++++--------- .../{dialog => }/permission_spec.rb | 2 +- .../overview_page/sidebar_spec.rb | 93 ++++-- .../overview_page/{dialog => }/update_spec.rb | 2 +- .../{dialog => }/validation_spec.rb | 2 +- .../overview_page/widget_spec.rb | 6 +- .../inplace_edit/field_registry_spec.rb | 32 ++ spec/models/custom_field_spec.rb | 20 ++ .../components/common/inplace_edit_field.rb | 131 ++++++++ .../common/inplace_edit_fields/dialog.rb | 130 ++++++++ spec/support/pages/projects/show.rb | 25 +- 25 files changed, 711 insertions(+), 287 deletions(-) rename spec/features/projects/project_custom_fields/overview_page/{dialog => }/attribute_help_texts_spec.rb (74%) rename spec/features/projects/project_custom_fields/overview_page/{dialog => }/inputs_spec.rb (75%) rename spec/features/projects/project_custom_fields/overview_page/{dialog => }/permission_spec.rb (99%) rename spec/features/projects/project_custom_fields/overview_page/{dialog => }/update_spec.rb (99%) rename spec/features/projects/project_custom_fields/overview_page/{dialog => }/validation_spec.rb (99%) create mode 100644 spec/support/components/common/inplace_edit_field.rb create mode 100644 spec/support/components/common/inplace_edit_fields/dialog.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.html.erb b/app/components/open_project/common/inplace_edit_field_component.html.erb index eeae7714d24..e11f8850545 100644 --- a/app/components/open_project/common/inplace_edit_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_component.html.erb @@ -5,7 +5,8 @@ data: { test_selector: wrapper_test_selector, turbo_stream_target: wrapper_id, - inplace_edit_stable_key: wrapper_uniq_by + inplace_edit_stable_key: wrapper_uniq_by, + inplace_edit_system_arguments: @system_arguments.to_json } ) do %> <% if display_field_component.present? && !enforce_edit_mode %> diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index 3bc5248435c..40ce1100513 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -91,16 +91,15 @@ module OpenProject end def wrapper_key - model_class = @model.class.name.parameterize(separator: "_") "op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}--#{@system_arguments[:id]}" end def wrapper_test_selector - "op-inplace-edit-field" + "op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}" end def wrapper_uniq_by - "#{@model.class.name.parameterize(separator: '_')}_#{@model.id}_#{@attribute}" + "#{model_class}_#{@model.id}_#{@attribute}" end def form_id @@ -120,7 +119,9 @@ module OpenProject attribute: @attribute ), method: :patch, - data: { turbo_stream: true } + data: { turbo_stream: true, + test_selector: "op-inplace-edit-field--form" } + } options[:id] = form_id if form_id.present? @@ -142,13 +143,16 @@ module OpenProject ) end + def model_class + @model_class ||= @model.class.name.parameterize(separator: "_") + end + private def dialog_display_arguments { dialog_controller_name: "inplace-edit", - dialog_url: dialog_edit_url, - dialog_test_selector: "inplace-edit-dialog-button-#{model.id}" + dialog_url: dialog_edit_url } end diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb index ae8522e0943..87aac78b03f 100644 --- a/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb @@ -8,7 +8,8 @@ ) ) do |d| d.with_header(variant: :large) - d.with_body(classes: "Overlay-body_autocomplete_height") do + d.with_body(classes: "Overlay-body_autocomplete_height", + test_selector: "async-dialog-content") do render(edit_component) end d.with_footer do diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 3eb7fba175d..9b181147068 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -51,8 +51,14 @@ module OpenProject if value.is_a?(TrueClass) || value.is_a?(FalseClass) boolean_display_value(value) + elsif value.is_a?(Date) || value.is_a?(Time) + helpers.format_date(value) elsif value.present? && value != [nil] - value.to_s + if custom_field? + helpers.format_value(value, custom_field) + else + value.to_s + end else t("placeholders.default") end @@ -87,7 +93,7 @@ module OpenProject controller: "inplace-edit async-dialog", inplace_edit_dialog_url_value: @system_arguments[:dialog_url], action: dialog_controller_actions, - test_selector: @system_arguments[:dialog_test_selector] + test_selector: "inplace-edit-dialog-button-#{model.id}" }, aria: { label: [ @@ -105,7 +111,8 @@ module OpenProject data: { controller: "inplace-edit", inplace_edit_url_value: edit_url, - action: inline_controller_actions + action: inline_controller_actions, + test_selector: "inplace-edit-field-button-#{model.id}" } } end @@ -146,7 +153,7 @@ module OpenProject end def boolean_display_value(value) - I18n.t("general_text_#{value ? 'yes' : 'no'}") + I18n.t("general_text_#{value ? 'Yes' : 'No'}") end def writable? diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index 7324effbe9d..d5ec4c49f13 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -210,11 +210,7 @@ class InplaceEditFieldsController < ApplicationController affected = affected_calculated_fields return if affected.empty? - # Inherit presentation args from the submitted system_arguments. - # Fields in the same container share the same context (e.g. both truncated - # in the sidebar), so this preserves the correct display for dependents. - presentation_args = system_arguments.to_h.symbolize_keys.slice(:truncated, :open_in_dialog) - affected.each { |custom_field| turbo_streams << calculated_field_turbo_stream(custom_field, presentation_args) } + affected.each { |custom_field| turbo_streams << calculated_field_turbo_stream(custom_field) } end def affected_calculated_fields @@ -222,15 +218,23 @@ class InplaceEditFieldsController < ApplicationController @model.available_custom_fields.affected_calculated_fields([cf_id]) end - def calculated_field_turbo_stream(custom_field, presentation_args) + def calculated_field_turbo_stream(custom_field) attribute = custom_field.attribute_name.to_sym + stable_key = "#{@model.class.name.parameterize(separator: '_')}_#{@model.id}_#{attribute}" + + # Use the field's own system_arguments sent by the client from the DOM data attribute. + # Fall back to an empty hash if not present (e.g. in tests or non-JS contexts). + field_args = stable_key_system_arguments + .fetch(stable_key, {}) + .symbolize_keys + .except(:id) # exclude UUID so the component generates a fresh one + comp = OpenProject::Common::InplaceEditFieldComponent.new( model: @model, attribute:, update_registry:, - **presentation_args + **field_args ) - stable_key = "#{@model.class.name.parameterize(separator: '_')}_#{@model.id}_#{attribute}" comp.render_as_turbo_stream( view_context:, action: :replace, @@ -239,6 +243,17 @@ class InplaceEditFieldsController < ApplicationController ) end + def stable_key_system_arguments + @stable_key_system_arguments ||= begin + raw = params[:stable_key_system_arguments] + return {} if raw.blank? + + JSON.parse(raw) + rescue JSON::ParserError + {} + end + end + def update_registry @update_registry ||= OpenProject::InplaceEdit::UpdateRegistry.default end diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index b676788ac5f..f646e6b65e8 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -86,6 +86,7 @@ class CustomField < ApplicationRecord validates :has_comment, absence: true, unless: :can_have_comment? before_validation :check_searchability + after_create :register_inplace_edit_component after_destroy :destroy_help_text # make sure int, float, date, and bool are not searchable @@ -479,4 +480,8 @@ class CustomField < ApplicationRecord .where(attribute_name:) .destroy_all end + + def register_inplace_edit_component + OpenProject::InplaceEdit::FieldRegistry.register_custom_field(id, field_format) + end end diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index f62e5f2c0bf..432fbb7e7c9 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -51,12 +51,11 @@ Rails.application.config.to_prepare do "calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent } + OpenProject::InplaceEdit::FieldRegistry.register_custom_field_format_mappings(custom_field_format_mappings) + if CustomField.table_exists? CustomField.pluck(:id, :field_format).each do |id, field_format| - component_class = custom_field_format_mappings[field_format] - if component_class - OpenProject::InplaceEdit::FieldRegistry.register("custom_field_#{id}", component_class) - end + OpenProject::InplaceEdit::FieldRegistry.register_custom_field(id, field_format) end end diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index 6ce73b8d853..76069248f63 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -42,6 +42,24 @@ export default class extends Controller { declare dialogUrlValue:string; declare hasDialogUrlValue:boolean; + private boundFormDataHandler: ((e: FormDataEvent) => void) | null = null; + + connect() { + const form = this.element.closest('form'); + if (form) { + this.boundFormDataHandler = (e: FormDataEvent) => this.appendStableKeySystemArguments(e); + form.addEventListener('formdata', this.boundFormDataHandler); + } + } + + disconnect() { + const form = this.element.closest('form'); + if (form && this.boundFormDataHandler) { + form.removeEventListener('formdata', this.boundFormDataHandler); + this.boundFormDataHandler = null; + } + } + async request(e:Event):Promise { // Don't trigger edit mode if the user is selecting text or just finished a selection if (window.getSelection()?.toString()) { @@ -93,6 +111,22 @@ export default class extends Controller { } } + private appendStableKeySystemArguments(e: FormDataEvent): void { + const result: Record = {}; + document.querySelectorAll('[data-inplace-edit-stable-key][data-inplace-edit-system-arguments]').forEach((el) => { + const key = el.dataset.inplaceEditStableKey; + const raw = el.dataset.inplaceEditSystemArguments; + if (key && raw) { + try { + result[key] = JSON.parse(raw); + } catch { + // ignore malformed JSON + } + } + }); + e.formData.append('stable_key_system_arguments', JSON.stringify(result)); + } + private isInteractiveElement(element:HTMLElement):boolean { // Check if the element is or is inside an interactive element. let current = element; diff --git a/lib/open_project/inplace_edit/field_registry.rb b/lib/open_project/inplace_edit/field_registry.rb index cc6817480c3..9449408193d 100644 --- a/lib/open_project/inplace_edit/field_registry.rb +++ b/lib/open_project/inplace_edit/field_registry.rb @@ -33,12 +33,22 @@ module OpenProject class FieldRegistry def initialize @registry = {} + @custom_field_format_mappings = {} end def register(attribute_name, field_component) @registry[attribute_name.to_s] = field_component end + def register_custom_field_format_mappings(mappings) + @custom_field_format_mappings = mappings + end + + def register_custom_field(id, field_format) + component = @custom_field_format_mappings[field_format] + register("custom_field_#{id}", component) if component + end + def fetch(attribute_name) @registry.fetch(attribute_name.to_s) { Common::InplaceEditFields::TextInputComponent } end @@ -48,7 +58,7 @@ module OpenProject class << self attr_reader :default - delegate :register, :fetch, to: :@default + delegate :register, :fetch, :register_custom_field_format_mappings, :register_custom_field, to: :@default end end end diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index 063627e75a8..99858232318 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -99,6 +99,10 @@ OpenProject::InplaceEdit::FieldRegistry.register( ) ``` +**Custom field registration:** + +Custom fields are registered automatically. The format-to-component mappings (e.g. `"text"` → `RichTextAreaComponent`) are configured once in `config/initializers/inplace_edit_fields.rb` via `register_custom_field_format_mappings`. At startup, all existing custom fields are pre-registered. Whenever a new custom field is created, an `after_create` callback on `CustomField` calls `FieldRegistry.register_custom_field(id, field_format)` automatically — no manual registration is needed. + #### BaseFieldComponent `BaseFieldComponent` is the **base class for all edit field components**. It provides shared functionality that concrete field components can use, and should be inherited from when building new field components. diff --git a/modules/overviews/spec/features/project_description_widget_spec.rb b/modules/overviews/spec/features/project_description_widget_spec.rb index becc4b766d8..88ec9a25aeb 100644 --- a/modules/overviews/spec/features/project_description_widget_spec.rb +++ b/modules/overviews/spec/features/project_description_widget_spec.rb @@ -75,18 +75,16 @@ RSpec.describe "Project description widget", :js do # Edit the project description # Find the editable description field - description_field = Turbo::TextEditorField.new(page, - "description", - selector:) + description_field = Components::Common::InplaceEditField.new(portfolio, :description) + # Activate the field for editing - description_field.activate! + description_field.open_field + + wait_for_network_idle # Set a new description new_description = "This is a **test** project description with markdown formatting." - description_field.set_value(new_description) - - # Save the changes - description_field.save! + description_field.fill_and_submit_value_via_button(name: "project[description]", val: new_description, ckeditor: true) tested_page.expect_and_dismiss_flash message: I18n.t("js.notice_successful_update") diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb index 8ed340c1ea9..5e9442e66ce 100644 --- a/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb @@ -53,14 +53,14 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::DisplayFie project = build_stubbed(:project, public: true) render_inline(described_class.new(model: project, attribute: :public, writable: false, truncated: false)) - expect(rendered_content).to have_text(I18n.t("general_text_yes")) + expect(rendered_content).to have_text(I18n.t("general_text_Yes")) end it "renders 'No' for a false boolean value" do project = build_stubbed(:project, public: false) render_inline(described_class.new(model: project, attribute: :public, writable: false, truncated: false)) - expect(rendered_content).to have_text(I18n.t("general_text_no")) + expect(rendered_content).to have_text(I18n.t("general_text_No")) end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/attribute_help_texts_spec.rb b/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb similarity index 74% rename from spec/features/projects/project_custom_fields/overview_page/dialog/attribute_help_texts_spec.rb rename to spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb index d221d016c4e..97b02108484 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/attribute_help_texts_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb @@ -29,7 +29,7 @@ #++ require "spec_helper" -require_relative "../shared_context" +require_relative "shared_context" RSpec.describe "Edit project custom fields on project overview page", "attribute help texts", :js do include_context "with seeded projects, members and project custom fields" @@ -46,10 +46,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute context "without attribute help texts defined" do it "shows field labels without help text link" do input_fields.each do |custom_field| - edit_dialog = overview_page.open_modal_for_custom_field(custom_field) - edit_dialog.expect_title "Input fields" - edit_dialog.expect_field_label_without_help_text custom_field.name - edit_dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field.expect_field_label_without_help_text custom_field.name + field.close end end end @@ -65,19 +64,17 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute it "shows field labels with help text link" do input_fields.each do |custom_field| - edit_dialog = overview_page.open_modal_for_custom_field(custom_field) - edit_dialog.expect_title "Input fields" - edit_dialog.expect_field_label_with_help_text custom_field.name - edit_dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field.expect_field_label_with_help_text custom_field.name + field.close end end context "without attachments" do it "shows help text modal on clicking help text link" do - edit_dialog = overview_page.open_modal_for_custom_field(date_project_custom_field) - edit_dialog.expect_title "Input fields" + field = overview_page.open_inplace_edit_field_for_custom_field(date_project_custom_field) - edit_dialog.click_help_text_link_for_label "Date field" + field.click_help_text_link_for_label "Date field" expect(page).to have_modal "Date field" within_modal "Date field" do @@ -94,10 +91,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute let!(:attachments) { create_list(:attachment, 2, container: integer_help_text) } it "shows help text modal, including attachments, on clicking help text link" do - edit_dialog = overview_page.open_modal_for_custom_field(integer_project_custom_field) - edit_dialog.expect_title "Input fields" + field = overview_page.open_inplace_edit_field_for_custom_field(integer_project_custom_field) - edit_dialog.click_help_text_link_for_label "Integer field" + field.click_help_text_link_for_label "Integer field" expect(page).to have_modal "Integer field" within_modal "Integer field" do expect(page).to have_text "Attribute help text" @@ -128,10 +124,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute context "without attribute help texts defined" do it "shows field labels without help text link" do select_fields.each do |custom_field| - edit_dialog = overview_page.open_modal_for_custom_field(custom_field) - edit_dialog.expect_title "Select fields" - edit_dialog.expect_field_label_without_help_text custom_field.name - edit_dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field.expect_field_label_without_help_text custom_field.name + field.close end end end @@ -143,18 +138,16 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute it "shows field labels with help text link" do select_fields.each do |custom_field| - edit_dialog = overview_page.open_modal_for_custom_field(custom_field) - edit_dialog.expect_title "Select fields" - edit_dialog.expect_field_label_with_help_text custom_field.name - edit_dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field.expect_field_label_with_help_text custom_field.name + field.close end end it "shows help text modal on clicking help text link" do - edit_dialog = overview_page.open_modal_for_custom_field(user_project_custom_field) - edit_dialog.expect_title "Select fields" + field = overview_page.open_inplace_edit_field_for_custom_field(user_project_custom_field) - edit_dialog.click_help_text_link_for_label "User field" + field.click_help_text_link_for_label "User field" expect(page).to have_modal "User field" within_modal "User field" do @@ -171,10 +164,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute context "without attribute help texts defined" do it "shows field labels without help text link" do multi_select_fields.each do |custom_field| - edit_dialog = overview_page.open_modal_for_custom_field(custom_field) - edit_dialog.expect_title "Multi select fields" - edit_dialog.expect_field_label_without_help_text custom_field.name - edit_dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field.expect_field_label_without_help_text custom_field.name + field.close end end end @@ -192,18 +184,16 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute it "shows field labels with help text link" do multi_select_fields.each do |custom_field| - edit_dialog = overview_page.open_modal_for_custom_field(custom_field) - edit_dialog.expect_title "Multi select fields" - edit_dialog.expect_field_label_with_help_text custom_field.name - edit_dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field.expect_field_label_with_help_text custom_field.name + field.close end end it "shows help text modal on clicking help text link" do - edit_dialog = overview_page.open_modal_for_custom_field(multi_list_project_custom_field) - edit_dialog.expect_title "Multi select fields" + field = overview_page.open_inplace_edit_field_for_custom_field(multi_list_project_custom_field) - edit_dialog.click_help_text_link_for_label "Multi list field" + field.click_help_text_link_for_label "Multi list field" expect(page).to have_modal "Multi list field" within_modal "Multi list field" do 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 965682fdbcc..513d0756745 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 @@ -29,46 +29,57 @@ #++ 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(:project) { create(:project) } + let(:admin) { create(:admin) } + + let(:project_custom_field_section) { create(:project_custom_field_section, name: "Section A") } + let(:text_project_custom_field) do + create(:text_project_custom_field, + name: "Required Foo", + project_custom_field_section:) + end let(:overview_page) { Pages::Projects::Show.new(project) } before do - login_as member_with_project_attributes_edit_permissions + create(:project_custom_field_project_mapping, project:, project_custom_field: text_project_custom_field) + login_as admin overview_page.visit_page end it "opens a dialog showing the input for project custom field" do - dialog = overview_page.open_modal_for_custom_field(boolean_project_custom_field) + field = overview_page.open_modal_for_custom_field(text_project_custom_field) + dialog = field.dialog dialog.expect_open dialog.within_async_content(close_after_yield: true) do - expect(page).to have_content(boolean_project_custom_field.name) + expect(page).to have_content(text_project_custom_field.name) end end it "renders the dialog body asynchronically" do - dialog = Components::Projects::ProjectCustomFields::Dialog.new(project, boolean_project_custom_field) - + dialog = Components::Common::InplaceEditFields::Dialog.new(project, text_project_custom_field.attribute_name.to_sym) expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) - overview_page.open_modal_for_custom_field(boolean_project_custom_field) + field = overview_page.open_modal_for_custom_field(text_project_custom_field) + dialog = field.dialog 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 - dialog = overview_page.open_modal_for_custom_field(boolean_project_custom_field) + field = overview_page.open_modal_for_custom_field(text_project_custom_field) + dialog = field.dialog dialog.close_via_icon dialog.expect_closed - dialog = overview_page.open_modal_for_custom_field(string_project_custom_field) + field = overview_page.open_modal_for_custom_field(text_project_custom_field) + dialog = field.dialog dialog.close_via_button diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb similarity index 75% rename from spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb rename to spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb index aae1607cc09..72c016d49fe 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb @@ -29,7 +29,7 @@ #++ require "spec_helper" -require_relative "../shared_context" +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" @@ -44,13 +44,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with correct initialization and input behaviour" do # not using let as dialog is closed every time, so new should be opened - def dialog = overview_page.open_modal_for_custom_field(custom_field) + def field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + def dialog = overview_page.open_modal_for_custom_field(custom_field).dialog shared_examples "shows comment input only when comments are allowed by custom field" do it "shows comment input only when comments are allowed by custom field" do - dialog.within_async_content(close_after_yield: true) do - expect(page).to have_no_field("Comment") - end + dialog.expect_closed custom_field.update!(has_comment: true) @@ -90,7 +89,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with input fields" do shared_examples "a custom field checkbox" do it "shows the correct value if given" do - dialog.within_async_content(close_after_yield: true) do + field.within_field do if expected_initial_value expect(page).to have_checked_field(custom_field.name) else @@ -102,23 +101,27 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "is unchecked if no value and no default value is given" do custom_field.custom_values.destroy_all - dialog.within_async_content(close_after_yield: true) do + field.within_field do expect(page).to have_no_checked_field(custom_field.name) end end - it "shows default value if no value is given" do + it "shows default true value if no value is given" do custom_field.custom_values.destroy_all custom_field.update!(default_value: true) - dialog.within_async_content(close_after_yield: true) do + field.within_field do expect(page).to have_checked_field(custom_field.name) end + end + + it "shows default false value if no value is given" do + custom_field.custom_values.destroy_all custom_field.update!(default_value: false) - dialog.within_async_content(close_after_yield: true) do + field.within_field do expect(page).to have_no_checked_field(custom_field.name) end end @@ -128,7 +131,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do shared_examples "a custom field input" do it "shows the correct value if given" do - dialog.within_async_content(close_after_yield: true) do + field.within_field do expect(page).to have_field(custom_field.name, with: expected_initial_value) end end @@ -136,7 +139,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "shows a blank input if no value or default value is given" do custom_field.custom_values.destroy_all - dialog.within_async_content(close_after_yield: true) do + field.within_field do expect(page).to have_field(custom_field.name, with: expected_blank_value) end end @@ -145,7 +148,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.custom_values.destroy_all custom_field.update!(default_value:) - dialog.within_async_content(close_after_yield: true) do + field.within_field do expect(page).to have_field(custom_field.name, with: default_value) end end @@ -259,7 +262,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with text CF" do let(:custom_field) { text_project_custom_field } - let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:form_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? @@ -318,50 +321,50 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with single select fields" do shared_examples "an autocomplete single select field" do it "shows the correct value if given" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_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 - overview_page.open_modal_for_custom_field(custom_field) - - field.expect_blank + field.within_field do + form_field.expect_blank + end end it "filters the list based on the input" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_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) + form_field.expect_option(second_option) + form_field.expect_no_option(first_option) + form_field.expect_no_option(third_option) + end end it "enables the user to select a single value from a list" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.search(second_option) + form_field.select_option(second_option) - field.search(second_option) - field.select_option(second_option) + form_field.expect_selected(second_option) - field.expect_selected(second_option) + form_field.search(third_option) + form_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) + form_field.expect_selected(third_option) + form_field.expect_not_selected(second_option) + end end it "clears the input if clicked on the clear button" do - overview_page.open_modal_for_custom_field(custom_field) - - field.clear - - field.expect_blank + field.within_field do + form_field.clear + form_field.expect_blank + end end include_examples "shows comment input only when comments are allowed by custom field" @@ -369,7 +372,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with single select list CF" do let(:custom_field) { list_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:expected_initial_value) { custom_field.custom_options.first.value } @@ -384,9 +387,9 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.custom_options.first.update!(default_value: true) - overview_page.open_modal_for_custom_field(custom_field) - - field.expect_selected(custom_field.custom_options.first.value) + field.within_field do + form_field.expect_selected(custom_field.custom_options.first.value) + end end it_behaves_like "displays readonly modal for user without edit permission" @@ -394,7 +397,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with single version select list CF" do let(:custom_field) { version_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:expected_initial_value) { first_version.name } @@ -411,11 +414,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only versions that are associated with this project" do - overview_page.open_modal_for_custom_field(custom_field) - - field.search("Version 1") - field.expect_option(first_version.name, grouping: project.name) - field.expect_no_option(version_in_other_project.name) + field.within_field do + form_field.search("Version 1") + form_field.expect_option(first_version.name, grouping: project.name) + form_field.expect_no_option(version_in_other_project.name) + end end end @@ -430,11 +433,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do let(:allow_non_open_versions) { false } it "does not shows closed version option" do - overview_page.open_modal_for_custom_field(custom_field) - field.open_options + field.within_field do + form_field.open_options - field.expect_option(first_version.name) - field.expect_no_option(closed_version.name) + form_field.expect_option(first_version.name) + form_field.expect_no_option(closed_version.name) + end end end @@ -442,11 +446,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do let(:allow_non_open_versions) { true } it "shows closed version option" do - overview_page.open_modal_for_custom_field(custom_field) - field.open_options + field.within_field do + form_field.open_options - field.expect_option(first_version.name) - field.expect_option(closed_version.name) + form_field.expect_option(first_version.name) + form_field.expect_option(closed_version.name) + end end end end @@ -457,7 +462,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with single user select list CF" do let(:custom_field) { user_project_custom_field } - let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:expected_initial_value) { member_in_project.name } @@ -476,12 +481,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only users that are members of the project" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.search("Member 1") - field.search("Member 1") - - field.expect_option(member_in_project.name) - field.expect_no_option(member_in_other_project.name) + form_field.expect_option(member_in_project.name) + form_field.expect_no_option(member_in_other_project.name) + end end end @@ -502,12 +507,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only groups that are associated with this project" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.search("Group 1") - field.search("Group 1") - - field.expect_option(group.name) - field.expect_no_option(group_in_other_project.name) + form_field.expect_option(group.name) + form_field.expect_no_option(group_in_other_project.name) + end end end @@ -518,11 +523,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows the placeholder user" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.search("Placeholder User") - field.search("Placeholder User") - - field.expect_option(placeholder_user.name) + form_field.expect_option(placeholder_user.name) + end end end @@ -533,67 +538,67 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with multi select fields" do shared_examples "an autocomplete multi select field" do it "shows the correct value if given" do - overview_page.open_modal_for_custom_field(custom_field) - - field.expect_selected(*expected_initial_value) + field.within_field do + form_field.expect_selected(*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_modal_for_custom_field(custom_field) - - field.expect_blank + field.within_field do + form_field.expect_blank + end end it "filters the list based on the input" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_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) + form_field.expect_option(second_option) + form_field.expect_no_option(first_option) + form_field.expect_no_option(third_option) + end end it "allows to select multiple values" do custom_field.custom_values.destroy_all - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.select_option(second_option) + form_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) + form_field.expect_selected(second_option) + form_field.expect_selected(third_option) + end end it "allows to remove selected values" do custom_field.custom_values.destroy_all - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.select_option(second_option) + form_field.select_option(third_option) - field.select_option(second_option) - field.select_option(third_option) + form_field.deselect_option(third_option) - field.deselect_option(third_option) - - field.expect_selected(second_option) - field.expect_not_selected(third_option) + form_field.expect_selected(second_option) + form_field.expect_not_selected(third_option) + end end it "allows to remove all selected values at once" do custom_field.custom_values.destroy_all - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.select_option(second_option) + form_field.select_option(third_option) - field.select_option(second_option) - field.select_option(third_option) + form_field.clear - field.clear - - field.expect_not_selected(second_option) - field.expect_not_selected(third_option) + form_field.expect_not_selected(second_option) + form_field.expect_not_selected(third_option) + end end include_examples "shows comment input only when comments are allowed by custom field" @@ -601,7 +606,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js 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(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } @@ -617,10 +622,10 @@ RSpec.describe "Edit project custom fields on project overview page", :js 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.open_modal_for_custom_field(custom_field) - - field.expect_selected(multi_list_project_custom_field.custom_options.first.value) - field.expect_selected(multi_list_project_custom_field.custom_options.second.value) + field.within_field do + form_field.expect_selected(multi_list_project_custom_field.custom_options.first.value) + form_field.expect_selected(multi_list_project_custom_field.custom_options.second.value) + end end it_behaves_like "displays readonly modal for user without edit permission" do @@ -630,7 +635,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js 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(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:expected_initial_value) { [first_version.name, second_version.name] } @@ -647,12 +652,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only versions that are associated with this project" do - overview_page.open_modal_for_custom_field(custom_field) - - field.search("Version 1") - - field.expect_option(first_version.name, grouping: project.name) - field.expect_no_option(version_in_other_project.name) + field.within_field do + form_field.search("Version 1") + form_field.expect_option(first_version.name, grouping: project.name) + form_field.expect_no_option(version_in_other_project.name) + end end end @@ -667,11 +671,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do let(:allow_non_open_versions) { false } it "does not shows closed version option" do - overview_page.open_modal_for_custom_field(custom_field) - field.open_options + field.within_field do + form_field.open_options - field.expect_option(first_version.name) - field.expect_no_option(closed_version.name) + form_field.expect_option(first_version.name) + form_field.expect_no_option(closed_version.name) + end end end @@ -679,11 +684,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do let(:allow_non_open_versions) { true } it "shows closed version option" do - overview_page.open_modal_for_custom_field(custom_field) - field.open_options + field.within_field do + form_field.open_options - field.expect_option(first_version.name) - field.expect_option(closed_version.name) + form_field.expect_option(first_version.name) + form_field.expect_option(closed_version.name) + end end end end @@ -696,7 +702,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js 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(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } @@ -715,12 +721,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only users that are members of the project" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.search("Member 1") - field.search("Member 1") - - field.expect_option(member_in_project.name) - field.expect_no_option(member_in_other_project.name) + form_field.expect_option(member_in_project.name) + form_field.expect_no_option(member_in_other_project.name) + end end end @@ -745,21 +751,21 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only groups that are associated with this project" do - overview_page.open_modal_for_custom_field(custom_field) - - field.search("Group 1") - field.expect_option(group.name) - field.expect_no_option(group_in_other_project.name) + field.within_field do + form_field.search("Group 1") + form_field.expect_option(group.name) + form_field.expect_no_option(group_in_other_project.name) + end end it "enables to select multiple user groups" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.select_option("Group 1 in project") + form_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") + form_field.expect_selected("Group 1 in project") + form_field.expect_selected("Group 2 in project") + end end end @@ -778,23 +784,23 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "shows only placeholder users from this project" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_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) + form_field.expect_option(placeholder_user.name) + form_field.expect_option(another_placeholder_user.name) + form_field.expect_no_option(placeholder_user_in_other_project.name) + end end it "enables to select multiple placeholder users" do - overview_page.open_modal_for_custom_field(custom_field) + field.within_field do + form_field.select_option(placeholder_user.name) + form_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) + form_field.expect_selected(placeholder_user.name) + form_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/permission_spec.rb similarity index 99% rename from spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb rename to spec/features/projects/project_custom_fields/overview_page/permission_spec.rb index 37685063d10..311f7b92406 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/permission_spec.rb @@ -29,7 +29,7 @@ #++ require "spec_helper" -require_relative "../shared_context" +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" 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 c4a0217d242..0782270f64e 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 @@ -199,7 +199,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do end end - it "does not show the default value for the project custom field if no value given" do + it "does 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 @@ -207,7 +207,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 "Yes" end end @@ -218,7 +218,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 "No" end end end @@ -272,7 +272,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do end end - it "does not show the default value for the project custom field if no value given" do + it "does 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 @@ -280,7 +280,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 "Bar" end end end @@ -334,7 +334,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do end end - it "does not show the default value for the project custom field if no value given" do + it "does 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 @@ -342,7 +342,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 456 end end end @@ -396,7 +396,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do end end - it "does not show the default value for the project custom field if no value given" do + it "does 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 @@ -404,7 +404,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 "02/02/2024" end end end @@ -458,7 +458,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do end end - it "dies not show the default value for the project custom field if no value given" do + it "does 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 @@ -466,7 +466,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 456.789 end end end @@ -556,7 +556,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do end end - it "does not show the default value for the project custom field if no value given" do + it "does 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 @@ -564,7 +564,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.within_project_attributes_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 "Dolor sit amet" end overview_page.expect_text_not_truncated(text_project_custom_field) @@ -662,9 +662,8 @@ RSpec.describe "Show project custom fields on project overview page", :js do overview_page.visit_page # Remove value that is used in a formula: - overview_page.open_modal_for_custom_field(float_project_custom_field) - page.fill_in(float_project_custom_field.name, with: "") - page.click_on "Save" + field = overview_page.open_inplace_edit_field_for_custom_field(float_project_custom_field) + field.fill_and_submit_value float_project_custom_field.name, "" overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(calculated_from_int_project_custom_field) do @@ -680,9 +679,8 @@ RSpec.describe "Show project custom fields on project overview page", :js do end # Change the value so that the calculation succeeds. - overview_page.open_modal_for_custom_field(float_project_custom_field) - page.fill_in(float_project_custom_field.name, with: "0.2") - page.click_on "Save" + field.open_field + field.fill_and_submit_value float_project_custom_field.name, "0.2" overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(calculated_from_int_project_custom_field) do @@ -747,15 +745,27 @@ RSpec.describe "Show project custom fields on project overview page", :js do 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) + context "with a new field" do + # We need to create a completely new field, just deleting the options is not enough... + let!(:new_list_project_custom_field) do + create(:list_project_custom_field, + projects: [project], + name: "New list field", + project_custom_field_section: section_for_select_fields, + possible_values: ["Option 1", "Option 2", "Option 3"]) do |field| + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first) + end + end - overview_page.visit_page + it "does show the default value for the project custom field if no value given" do + new_list_project_custom_field.custom_options.first.update!(default_value: true) + overview_page.visit_page - overview_page.within_project_attributes_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") + overview_page.within_project_attributes_sidebar do + overview_page.within_custom_field_container(new_list_project_custom_field) do + expect(page).to have_text "New list field" + expect(page).to have_text "Option 1" + end end end end @@ -916,16 +926,31 @@ RSpec.describe "Show project custom fields on project overview page", :js do 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) + context "with a new field" do + # We need to create a completely new field, just deleting the options is not enough... + let!(:new_multi_list_project_custom_field) do + create(:list_project_custom_field, + projects: [project], + name: "New multi list field", + project_custom_field_section: section_for_multi_select_fields, + possible_values: ["Option 1", "Option 2", "Option 3"], + multi_value: true) do |field| + 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) + end + end - overview_page.visit_page + it "does not show the default value(s) for the project custom field if no value given" do + new_multi_list_project_custom_field.custom_options.first.update!(default_value: true) + new_multi_list_project_custom_field.custom_options.second.update!(default_value: true) - overview_page.within_project_attributes_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") + overview_page.visit_page + + overview_page.within_project_attributes_sidebar do + overview_page.within_custom_field_container(new_multi_list_project_custom_field) do + expect(page).to have_text "New multi list field" + expect(page).to have_text "Option 1, Option 2" + 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/update_spec.rb similarity index 99% rename from spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb rename to spec/features/projects/project_custom_fields/overview_page/update_spec.rb index 1a482609a17..fdd7ceadf0a 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/update_spec.rb @@ -29,7 +29,7 @@ #++ require "spec_helper" -require_relative "../shared_context" +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" diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/validation_spec.rb similarity index 99% rename from spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb rename to spec/features/projects/project_custom_fields/overview_page/validation_spec.rb index 43ead7a04b0..49104aba9dc 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/validation_spec.rb @@ -29,7 +29,7 @@ #++ require "spec_helper" -require_relative "../shared_context" +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" diff --git a/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb b/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb index d8176fb05ed..b169d1f800e 100644 --- a/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb @@ -156,9 +156,9 @@ RSpec.describe "Show project custom fields on project overview page", :js do end it "can edit a project custom field from within the widget" do - overview_page.open_modal_for_custom_field(string_project_custom_field) - page.fill_in(string_project_custom_field.name, with: "My super awesome new value") - page.click_on "Save" + field = overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field) + field.fill_and_submit_value string_project_custom_field.name, "My super awesome new value" + # The new value is shown in the widget overview_page.within_main_area do diff --git a/spec/lib/open_project/inplace_edit/field_registry_spec.rb b/spec/lib/open_project/inplace_edit/field_registry_spec.rb index 36726cb51f6..b9e96b87904 100644 --- a/spec/lib/open_project/inplace_edit/field_registry_spec.rb +++ b/spec/lib/open_project/inplace_edit/field_registry_spec.rb @@ -61,4 +61,36 @@ RSpec.describe OpenProject::InplaceEdit::FieldRegistry do expect(registry.fetch(:description)).to eq(rich_text_component) end end + + describe "#register_custom_field_format_mappings" do + it "stores format-to-component mappings used by register_custom_field" do + text_component = Class.new + registry.register_custom_field_format_mappings("text" => text_component) + + registry.register_custom_field(42, "text") + + expect(registry.fetch("custom_field_42")).to eq(text_component) + end + end + + describe "#register_custom_field" do + let(:text_component) { Class.new } + + before do + registry.register_custom_field_format_mappings("text" => text_component) + end + + it "registers the correct component for the given field format" do + registry.register_custom_field(1, "text") + + expect(registry.fetch("custom_field_1")).to eq(text_component) + end + + it "does nothing when the format has no mapping" do + registry.register_custom_field(2, "unknown_format") + + expect(registry.fetch("custom_field_2")) + .to eq(OpenProject::Common::InplaceEditFields::TextInputComponent) + end + end end diff --git a/spec/models/custom_field_spec.rb b/spec/models/custom_field_spec.rb index fbf86a82327..658dbcd0ae2 100644 --- a/spec/models/custom_field_spec.rb +++ b/spec/models/custom_field_spec.rb @@ -732,4 +732,24 @@ RSpec.describe CustomField do end end end + + describe "after_create callback" do + it "registers the custom field in the inplace edit field registry" do + custom_field = build(:custom_field, field_format: "string") + + expect(OpenProject::InplaceEdit::FieldRegistry) + .to receive(:register_custom_field) + .with(anything, "string") + + custom_field.save! + end + + it "does not re-register when updated" do + custom_field = create(:custom_field, field_format: "string") + + expect(OpenProject::InplaceEdit::FieldRegistry).not_to receive(:register_custom_field) + + custom_field.update!(name: "Updated name") + end + end end diff --git a/spec/support/components/common/inplace_edit_field.rb b/spec/support/components/common/inplace_edit_field.rb new file mode 100644 index 00000000000..fa613d51ece --- /dev/null +++ b/spec/support/components/common/inplace_edit_field.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require_relative "../../flash/expectations" + +module Components + module Common + class InplaceEditField + include Capybara::DSL + include Capybara::RSpecMatchers + include RSpec::Matchers + + attr_reader :model, :attribute, :show_in_dialog, :model_class + + def initialize(model, attribute, show_in_dialog: false) + @model = model + @attribute = attribute + @show_in_dialog = show_in_dialog + if show_in_dialog + @dialog = InplaceEditFields::Dialog.new(model, attribute) + end + + @model_class = @model.class.name.parameterize(separator: "_") + end + + def open_field + within_field do + # Link and user type custom fields might contain a clickable link inside the edit container. + # Use JavaScript to directly trigger the click event on the container to avoid nested links. + page.execute_script( + "document.querySelector('[data-test-selector=\"op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}\"] .op-inplace-edit--display-field').click()" + ) + end + end + + def expect_open + if show_in_dialog + dialog.expect_open + else + within_field do + expect(page).to have_test_selector("op-inplace-edit-field--form") + end + end + end + + def close + if show_in_dialog + dialog.close + else + # todo + end + end + + def expect_field_label_with_help_text(label_text) + expect_field_label(label_text) + expect(find_field_label(label_text)).to have_link accessible_name: "Show help text" + end + + def expect_field_label_without_help_text(label_text) + expect_field_label(label_text) + expect(find_field_label(label_text)).to have_no_link accessible_name: "Show help text" + end + + def click_help_text_link_for_label(label_text) + link = find_field_label(label_text).find(:link, accessible_name: "Show help text") + link.click + end + + def fill_and_submit_value(name, val) + fill_in(name, with: val).send_keys(:return) + wait_for_network_idle + end + + def fill_and_submit_value_via_button(name:, val:, ckeditor: false) + within_field do + if ckeditor + find(".ck-content").base.send_keys val + else + fill_in(name, with: val) + end + click_on "Save" + end + end + + def dialog + @dialog + end + + def within_field(&) + page.within_test_selector("op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}", &) + end + + private + + def expect_field_label(label_text) + expect(page).to have_element :label, text: label_text + end + + def find_field_label(label_text) + page.find(:element, :label, text: label_text) + end + end + end +end diff --git a/spec/support/components/common/inplace_edit_fields/dialog.rb b/spec/support/components/common/inplace_edit_fields/dialog.rb new file mode 100644 index 00000000000..09103a41e65 --- /dev/null +++ b/spec/support/components/common/inplace_edit_fields/dialog.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "support/components/common/modal" +require "support/components/autocompleter/ng_select_autocomplete_helpers" + +module Components + module Common + class InplaceEditFields + class Dialog < Components::Common::Modal + include Components::Autocompleter::NgSelectAutocompleteHelpers + + attr_reader :model, :attribute + + def initialize(model, attribute) + super() + + @model = model + @attribute = attribute + @model_class = @model.class.name.parameterize(separator: "_") + end + + def dialog_css_selector + "dialog#inplace-edit-field-dialog--#{@model_class}-#{model.id}--#{attribute.name}" + end + + def async_content_container_css_selector + "#{dialog_css_selector} [data-test-selector='async-dialog-content']" + end + + def within_dialog(close_after_yield: false, &) + within(dialog_css_selector, &).tap do + close if close_after_yield + end + end + + def within_async_content(close_after_yield: false, &) + within(async_content_container_css_selector, &).tap do + close if close_after_yield + end + 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 submit + within(dialog_css_selector) do + click_link_or_button "Save" + 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 expect_field_label_with_help_text(label_text) + expect_field_label(label_text) + expect(find_field_label(label_text)).to have_link accessible_name: "Show help text" + end + + def expect_field_label_without_help_text(label_text) + expect_field_label(label_text) + expect(find_field_label(label_text)).to have_no_link accessible_name: "Show help text" + end + + def click_help_text_link_for_label(label_text) + link = find_field_label(label_text).find(:link, accessible_name: "Show help text") + link.click + end + + def expect_field_label(label_text) + within_dialog do + expect(page).to have_element :label, text: label_text + end + end + + def find_field_label(label_text) + within_dialog do + page.find(:element, :label, text: label_text) + end + end + end + end + end +end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 4766c7cbcda..8b494d3ed16 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -93,21 +93,22 @@ module Pages def open_modal_for_custom_field(custom_field) scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']")) - within_custom_field_container(custom_field) do - # Link and user type custom fields might contain a clickable link inside the edit container. - # Use JavaScript to directly trigger the click event on the container to avoid nested links. - # Once we create the project custom field inline editing, this can be reverted to a normal - # capybara click method call. - page.execute_script( - "document.querySelector('[data-test-selector=\"inplace-edit-dialog-button-#{custom_field.id}\"]').click()" - ) - end + field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym, show_in_dialog: true) + field.open_field - dialog = Components::Projects::ProjectCustomFields::Dialog.new(project, custom_field) + wait_for_size_animation_completion(field.dialog.dialog_css_selector) - wait_for_size_animation_completion(dialog.dialog_css_selector) + field + end - dialog + def open_inplace_edit_field_for_custom_field(custom_field) + scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']")) + field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym) + field.open_field + + wait_for_network_idle + + field end def open_edit_dialog_for_life_cycle(life_cycle, wait_angular: false) From 3b91c63fb21d20ed16df6be11f75f63850624983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 16:01:52 +0100 Subject: [PATCH 211/435] Update hocuspocus image to openproject/hocuspocus:17.2.2 --- docker/prod/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile index e5a9953887e..bc73fdc9e42 100755 --- a/docker/prod/Dockerfile +++ b/docker/prod/Dockerfile @@ -140,7 +140,7 @@ ENV PGDATA=/var/openproject/pgdata COPY --from=openproject/gosu /go/bin/gosu /usr/local/bin/gosu RUN chmod +x /usr/local/bin/gosu && gosu nobody true -COPY --from=openproject/hocuspocus:17.2.1 --chown=$APP_USER:$APP_USER /app /opt/hocuspocus +COPY --from=openproject/hocuspocus:17.2.2 --chown=$APP_USER:$APP_USER /app /opt/hocuspocus # Keep node/npm in all-in-one for bundled hocuspocus even when BIM support is disabled. COPY --from=build-base /usr/local/bin/node /usr/local/bin/node COPY --from=build-base /usr/local/lib/node_modules /usr/local/lib/node_modules From 518b3dacb09ba0c5343f376a8099522183655915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 16:01:53 +0100 Subject: [PATCH 212/435] Update security fixes --- docs/release-notes/17-2-2/README.md | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/release-notes/17-2-2/README.md diff --git a/docs/release-notes/17-2-2/README.md b/docs/release-notes/17-2-2/README.md new file mode 100644 index 00000000000..7a4fe6f21e3 --- /dev/null +++ b/docs/release-notes/17-2-2/README.md @@ -0,0 +1,33 @@ +--- +title: OpenProject 17.2.2 +sidebar_navigation: + title: 17.2.2 +release_version: 17.2.2 +release_date: 2026-03-17 +--- + + # OpenProject 17.2.2 + + Release date: 2026-03-17 + + We released OpenProject [OpenProject 17.2.2](https://community.openproject.org/versions/2284). + The release contains several bug fixes and we recommend updating to the newest version. + Below you will find a complete list of all changes and bug fixes. + + + + + + + +## Bug fixes and changes + + + + +- Bugfix: Send test email fails when using SMTP and TLS \[[#73099](https://community.openproject.org/wp/73099)\] +- Bugfix: Webhook sends wrong Content-Type header \[[#73145](https://community.openproject.org/wp/73145)\] +- Bugfix: Cannot set the "Used as backlog" field in the Project Version settings. \[[#73187](https://community.openproject.org/wp/73187)\] + + + From 6ea4cfdcf467f1c40ac7902270e50d09dae1cfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 16:01:54 +0100 Subject: [PATCH 213/435] Add release-notes file --- docs/release-notes/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/README.md b/docs/release-notes/README.md index 87a363daf2f..1f5645ff4d1 100644 --- a/docs/release-notes/README.md +++ b/docs/release-notes/README.md @@ -13,6 +13,13 @@ Stay up to date and get an overview of the new features included in the releases +## 17.2.2 + +Release date: 2026-03-17 + +[Release Notes](17-2-2/) + + ## 17.2.1 Release date: 2026-03-16 From d97c35e07edc95ffd3afc5fa06e701df9bf13f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 16:01:54 +0100 Subject: [PATCH 214/435] Update publiccode.yml --- publiccode.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/publiccode.yml b/publiccode.yml index f8ff7188924..a4c4f9261bf 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -7,8 +7,8 @@ name: OpenProject applicationSuite: openDesk url: 'https://github.com/opf/openproject' roadmap: 'https://www.openproject.org/roadmap' -releaseDate: '2026-03-16' -softwareVersion: '17.2.1' +releaseDate: '2026-03-17' +softwareVersion: '17.2.2' developmentStatus: stable softwareType: standalone/web logo: 'publiccode_logo.svg' From e209be59e3caf709bc2e267e3d7ae63aa546c85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 17 Mar 2026 16:01:55 +0100 Subject: [PATCH 215/435] Bumped version to 17.2.3 [ci skip] --- lib/open_project/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/open_project/version.rb b/lib/open_project/version.rb index e0514f452ad..17808308b57 100644 --- a/lib/open_project/version.rb +++ b/lib/open_project/version.rb @@ -33,7 +33,7 @@ module OpenProject module VERSION # :nodoc: MAJOR = 17 MINOR = 2 - PATCH = 2 + PATCH = 3 class << self def revision From 5e6feb307b55369eae53a7e51cfa8ed525424fc6 Mon Sep 17 00:00:00 2001 From: JohannaStriebing Date: Tue, 17 Mar 2026 16:02:52 +0100 Subject: [PATCH 216/435] task/49064-harmonize-writing-of-drop-down-vs-dropdown task/49064-harmonize-writing-of-drop-down-vs-dropdown --- docs/getting-started/work-packages-introduction/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/work-packages-introduction/README.md b/docs/getting-started/work-packages-introduction/README.md index 641de8a638f..64690dfec24 100644 --- a/docs/getting-started/work-packages-introduction/README.md +++ b/docs/getting-started/work-packages-introduction/README.md @@ -27,7 +27,7 @@ A work package in OpenProject can basically be everything you need to keep track ## Create a new work package -To get started, create a new work package in your project, [open the project](../projects/#open-an-existing-project) with the project drop-down menu, navigate to the **work packages module** in the project menu. +To get started, create a new work package in your project, [open the project](../projects/#open-an-existing-project) with the project dropdown menu, navigate to the **work packages module** in the project menu. Within the work packages module, click the + Create button to create a new work package. In the drop down menu, choose which type of work package you want to create, e.g. a task or a milestone. @@ -69,7 +69,7 @@ Click any of the fields to **update a work package**, e.g. description. Click th ![Update a work package in a split screen view in OpenProject](openproject_getting_started_work_packages_wp_detailed_view_edit.png) -To **update the status**, click on the highlighted displayed status on top of the form and select the new status from the drop-down. +To **update the status**, click on the highlighted displayed status on top of the form and select the new status from the dropdown. ![Work package status dropdown menu opened in a detailed work package view in OpenProject](openproject_getting_started_work_packages_update_status.png) From 320ffd8ab67e52d733b8107ff6a48bfd35a42fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 16 Mar 2026 14:10:37 +0100 Subject: [PATCH 217/435] Push packages on tag push (#22367) * Push packages on tag push * Use channel/X derived from tag name --- .github/workflows/packager.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packager.yml b/.github/workflows/packager.yml index 30ec8fb6abd..095491ece13 100644 --- a/.github/workflows/packager.yml +++ b/.github/workflows/packager.yml @@ -1,10 +1,11 @@ name: Package on: push: + tags: + - v* branches: - packaging/* - release/* - - stable/* workflow_dispatch: schedule: - cron: '0 3 * * *' # Daily at 03:00 @@ -42,6 +43,12 @@ jobs: run: | VERSION=$(ruby -r ./lib/open_project/version.rb -e "puts OpenProject::VERSION") echo "version=$VERSION" >> $GITHUB_OUTPUT + if [[ "${{ github.ref_type }}" == "tag" ]]; then + MAJOR=$(ruby -r ./lib/open_project/version.rb -e "puts OpenProject::VERSION::MAJOR") + echo "channel=stable/${MAJOR}" >> $GITHUB_OUTPUT + else + echo "channel=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi - name: Package uses: pkgr/action/package@main id: package @@ -58,5 +65,5 @@ jobs: target: ${{ matrix.target }} token: ${{ secrets.PACKAGER_PUBLISH_TOKEN }} repository: opf/openproject - channel: ${{ github.ref_name }} + channel: ${{ steps.setup.outputs.channel }} file: ${{ steps.package.outputs.package_path }} From d4a51a65cc2df56f62ee69c6878d10881dd886bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 16 Mar 2026 14:10:37 +0100 Subject: [PATCH 218/435] Push packages on tag push (#22367) * Push packages on tag push * Use channel/X derived from tag name --- .github/workflows/packager.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packager.yml b/.github/workflows/packager.yml index 30ec8fb6abd..095491ece13 100644 --- a/.github/workflows/packager.yml +++ b/.github/workflows/packager.yml @@ -1,10 +1,11 @@ name: Package on: push: + tags: + - v* branches: - packaging/* - release/* - - stable/* workflow_dispatch: schedule: - cron: '0 3 * * *' # Daily at 03:00 @@ -42,6 +43,12 @@ jobs: run: | VERSION=$(ruby -r ./lib/open_project/version.rb -e "puts OpenProject::VERSION") echo "version=$VERSION" >> $GITHUB_OUTPUT + if [[ "${{ github.ref_type }}" == "tag" ]]; then + MAJOR=$(ruby -r ./lib/open_project/version.rb -e "puts OpenProject::VERSION::MAJOR") + echo "channel=stable/${MAJOR}" >> $GITHUB_OUTPUT + else + echo "channel=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi - name: Package uses: pkgr/action/package@main id: package @@ -58,5 +65,5 @@ jobs: target: ${{ matrix.target }} token: ${{ secrets.PACKAGER_PUBLISH_TOKEN }} repository: opf/openproject - channel: ${{ github.ref_name }} + channel: ${{ steps.setup.outputs.channel }} file: ${{ steps.package.outputs.package_path }} From 5043e46047db0ff24ac6b6a77cfc619828aab86b Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Wed, 18 Mar 2026 03:57:29 +0000 Subject: [PATCH 219/435] update locales from crowdin [ci skip] --- config/locales/crowdin/af.yml | 5 ++--- config/locales/crowdin/ar.yml | 5 ++--- config/locales/crowdin/az.yml | 5 ++--- config/locales/crowdin/be.yml | 5 ++--- config/locales/crowdin/bg.yml | 5 ++--- config/locales/crowdin/ca.yml | 5 ++--- config/locales/crowdin/ckb-IR.yml | 5 ++--- config/locales/crowdin/cs.yml | 7 +++---- config/locales/crowdin/da.yml | 5 ++--- config/locales/crowdin/de.yml | 5 ++--- config/locales/crowdin/el.yml | 5 ++--- config/locales/crowdin/eo.yml | 5 ++--- config/locales/crowdin/es.yml | 5 ++--- config/locales/crowdin/et.yml | 5 ++--- config/locales/crowdin/eu.yml | 5 ++--- config/locales/crowdin/fa.yml | 5 ++--- config/locales/crowdin/fi.yml | 5 ++--- config/locales/crowdin/fil.yml | 5 ++--- config/locales/crowdin/fr.yml | 5 ++--- config/locales/crowdin/he.yml | 5 ++--- config/locales/crowdin/hi.yml | 5 ++--- config/locales/crowdin/hr.yml | 5 ++--- config/locales/crowdin/hu.yml | 5 ++--- config/locales/crowdin/id.yml | 5 ++--- config/locales/crowdin/it.yml | 5 ++--- config/locales/crowdin/ja.yml | 5 ++--- config/locales/crowdin/js-fr.yml | 2 +- config/locales/crowdin/ka.yml | 5 ++--- config/locales/crowdin/kk.yml | 5 ++--- config/locales/crowdin/ko.yml | 5 ++--- config/locales/crowdin/lt.yml | 5 ++--- config/locales/crowdin/lv.yml | 5 ++--- config/locales/crowdin/mn.yml | 5 ++--- config/locales/crowdin/ms.yml | 5 ++--- config/locales/crowdin/ne.yml | 5 ++--- config/locales/crowdin/nl.yml | 5 ++--- config/locales/crowdin/no.yml | 5 ++--- config/locales/crowdin/pl.yml | 5 ++--- config/locales/crowdin/pt-BR.yml | 5 ++--- config/locales/crowdin/pt-PT.yml | 5 ++--- config/locales/crowdin/ro.yml | 5 ++--- config/locales/crowdin/ru.yml | 5 ++--- config/locales/crowdin/rw.yml | 5 ++--- config/locales/crowdin/si.yml | 5 ++--- config/locales/crowdin/sk.yml | 5 ++--- config/locales/crowdin/sl.yml | 5 ++--- config/locales/crowdin/sr.yml | 5 ++--- config/locales/crowdin/sv.yml | 5 ++--- config/locales/crowdin/th.yml | 5 ++--- config/locales/crowdin/tr.yml | 5 ++--- config/locales/crowdin/uk.yml | 5 ++--- config/locales/crowdin/uz.yml | 5 ++--- config/locales/crowdin/vi.yml | 5 ++--- config/locales/crowdin/zh-CN.yml | 5 ++--- config/locales/crowdin/zh-TW.yml | 5 ++--- modules/budgets/config/locales/crowdin/cs.yml | 4 ++-- modules/costs/config/locales/crowdin/cs.yml | 16 ++++++++-------- modules/documents/config/locales/crowdin/cs.yml | 2 +- modules/grids/config/locales/crowdin/cs.yml | 6 +++--- modules/grids/config/locales/crowdin/js-cs.yml | 2 +- modules/meeting/config/locales/crowdin/cs.yml | 2 +- modules/meeting/config/locales/crowdin/fr.yml | 2 +- modules/storages/config/locales/crowdin/af.yml | 6 +++++- modules/storages/config/locales/crowdin/ar.yml | 6 +++++- modules/storages/config/locales/crowdin/az.yml | 6 +++++- modules/storages/config/locales/crowdin/be.yml | 6 +++++- modules/storages/config/locales/crowdin/bg.yml | 6 +++++- modules/storages/config/locales/crowdin/ca.yml | 6 +++++- .../storages/config/locales/crowdin/ckb-IR.yml | 6 +++++- modules/storages/config/locales/crowdin/cs.yml | 6 +++++- modules/storages/config/locales/crowdin/da.yml | 6 +++++- modules/storages/config/locales/crowdin/de.yml | 6 +++++- modules/storages/config/locales/crowdin/el.yml | 6 +++++- modules/storages/config/locales/crowdin/eo.yml | 6 +++++- modules/storages/config/locales/crowdin/es.yml | 6 +++++- modules/storages/config/locales/crowdin/et.yml | 6 +++++- modules/storages/config/locales/crowdin/eu.yml | 6 +++++- modules/storages/config/locales/crowdin/fa.yml | 6 +++++- modules/storages/config/locales/crowdin/fi.yml | 6 +++++- modules/storages/config/locales/crowdin/fil.yml | 6 +++++- modules/storages/config/locales/crowdin/fr.yml | 6 +++++- modules/storages/config/locales/crowdin/he.yml | 6 +++++- modules/storages/config/locales/crowdin/hi.yml | 6 +++++- modules/storages/config/locales/crowdin/hr.yml | 6 +++++- modules/storages/config/locales/crowdin/hu.yml | 6 +++++- modules/storages/config/locales/crowdin/id.yml | 6 +++++- modules/storages/config/locales/crowdin/it.yml | 6 +++++- modules/storages/config/locales/crowdin/ja.yml | 6 +++++- modules/storages/config/locales/crowdin/ka.yml | 6 +++++- modules/storages/config/locales/crowdin/kk.yml | 6 +++++- modules/storages/config/locales/crowdin/ko.yml | 6 +++++- modules/storages/config/locales/crowdin/lt.yml | 6 +++++- modules/storages/config/locales/crowdin/lv.yml | 6 +++++- modules/storages/config/locales/crowdin/mn.yml | 6 +++++- modules/storages/config/locales/crowdin/ms.yml | 6 +++++- modules/storages/config/locales/crowdin/ne.yml | 6 +++++- modules/storages/config/locales/crowdin/nl.yml | 6 +++++- modules/storages/config/locales/crowdin/no.yml | 6 +++++- modules/storages/config/locales/crowdin/pl.yml | 6 +++++- .../storages/config/locales/crowdin/pt-BR.yml | 6 +++++- .../storages/config/locales/crowdin/pt-PT.yml | 6 +++++- modules/storages/config/locales/crowdin/ro.yml | 6 +++++- modules/storages/config/locales/crowdin/ru.yml | 6 +++++- modules/storages/config/locales/crowdin/rw.yml | 6 +++++- modules/storages/config/locales/crowdin/si.yml | 6 +++++- modules/storages/config/locales/crowdin/sk.yml | 6 +++++- modules/storages/config/locales/crowdin/sl.yml | 6 +++++- modules/storages/config/locales/crowdin/sr.yml | 6 +++++- modules/storages/config/locales/crowdin/sv.yml | 6 +++++- modules/storages/config/locales/crowdin/th.yml | 6 +++++- modules/storages/config/locales/crowdin/tr.yml | 6 +++++- modules/storages/config/locales/crowdin/uk.yml | 6 +++++- modules/storages/config/locales/crowdin/uz.yml | 6 +++++- modules/storages/config/locales/crowdin/vi.yml | 6 +++++- .../storages/config/locales/crowdin/zh-CN.yml | 6 +++++- .../storages/config/locales/crowdin/zh-TW.yml | 6 +++++- 116 files changed, 397 insertions(+), 235 deletions(-) diff --git a/config/locales/crowdin/af.yml b/config/locales/crowdin/af.yml index ddfc4cb7dff..8dd1ab60278 100644 --- a/config/locales/crowdin/af.yml +++ b/config/locales/crowdin/af.yml @@ -107,9 +107,8 @@ af: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ar.yml b/config/locales/crowdin/ar.yml index c16f41c6f96..1d9d1a10364 100644 --- a/config/locales/crowdin/ar.yml +++ b/config/locales/crowdin/ar.yml @@ -107,9 +107,8 @@ ar: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/az.yml b/config/locales/crowdin/az.yml index 3c256c7eb21..1c5cbe941e5 100644 --- a/config/locales/crowdin/az.yml +++ b/config/locales/crowdin/az.yml @@ -107,9 +107,8 @@ az: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/be.yml b/config/locales/crowdin/be.yml index b14d87b18ee..af21e09648e 100644 --- a/config/locales/crowdin/be.yml +++ b/config/locales/crowdin/be.yml @@ -107,9 +107,8 @@ be: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/bg.yml b/config/locales/crowdin/bg.yml index 080b851c54d..097d8d3fc9c 100644 --- a/config/locales/crowdin/bg.yml +++ b/config/locales/crowdin/bg.yml @@ -107,9 +107,8 @@ bg: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ca.yml b/config/locales/crowdin/ca.yml index 1abfd162013..e9e47fb3969 100644 --- a/config/locales/crowdin/ca.yml +++ b/config/locales/crowdin/ca.yml @@ -107,9 +107,8 @@ ca: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator journal_aggregation: - explanation: - text: "Les accions individuals d'un sol usuari (per exemple actualitzar dos cops un paquet de treball) seran agregades en una sola acció si la diferència temporal és menor a l'especificada. Aquests seran exposats com una acció individual dins l'aplicació. Això, també reduïra el número d'emails enviats i el retràs en el %{webhook_link} ja que les notificacións també seran retrasades." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ckb-IR.yml b/config/locales/crowdin/ckb-IR.yml index e2924baff2b..5a6ee65394a 100644 --- a/config/locales/crowdin/ckb-IR.yml +++ b/config/locales/crowdin/ckb-IR.yml @@ -107,9 +107,8 @@ ckb-IR: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index 12299379605..11e5050912f 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -107,9 +107,8 @@ cs: trial: "Trial" jemalloc_allocator: Jemalloc alokátor paměti journal_aggregation: - explanation: - text: "Individuální akce/úpravy uživatele (např. dvojnásobná aktualizace pracovního balíčku se sečtou do jediné akce, pokud je jejich časový rozdíl menší než stanovený čas. Budou zobrazeny jako jedna akce v rámci aplikace. Toto také zpozdí oznámení o stejný čas a sníží tak počet zasílaných e-mailů a ovlivní se také zpoždění na adrese %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: @@ -3960,7 +3959,7 @@ cs: label_subproject: "Podprojekt" label_subproject_new: "Nový podprojekt" label_subproject_plural: "Podprojekty" - label_subitems: "Subitems" + label_subitems: "Dílčí položky" label_subtask_plural: "Podúkoly" label_summary: "Souhrn" label_system: "Systém" diff --git a/config/locales/crowdin/da.yml b/config/locales/crowdin/da.yml index ce9ff362827..165280fc7e2 100644 --- a/config/locales/crowdin/da.yml +++ b/config/locales/crowdin/da.yml @@ -107,9 +107,8 @@ da: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/de.yml b/config/locales/crowdin/de.yml index 4d4f9569cd6..e12bae225c1 100644 --- a/config/locales/crowdin/de.yml +++ b/config/locales/crowdin/de.yml @@ -107,9 +107,8 @@ de: trial: "Probezeitraum" jemalloc_allocator: Jemalloc Speicher allocator journal_aggregation: - explanation: - text: "Individuelle Aktionen eines Benutzers (z.B. ein Arbeitspaket zweimal aktualisieren) werden zu einer einzigen Aktion zusammengefasst, wenn ihr Altersunterschied kleiner ist als der angegebene Zeitraum. Sie werden als eine einzige Aktion innerhalb der Anwendung angezeigt. Dadurch werden Benachrichtigungen um die gleiche Zeit verzögert, wodurch die Anzahl der gesendeten E-Mails verringert wird. Dies wirkt sich auch auf die Verzögerung von %{webhook_link} aus." - link: "Webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/el.yml b/config/locales/crowdin/el.yml index e6a917d64a3..f20b4a008f2 100644 --- a/config/locales/crowdin/el.yml +++ b/config/locales/crowdin/el.yml @@ -107,9 +107,8 @@ el: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/eo.yml b/config/locales/crowdin/eo.yml index 5bdb1559816..0e2436cab51 100644 --- a/config/locales/crowdin/eo.yml +++ b/config/locales/crowdin/eo.yml @@ -107,9 +107,8 @@ eo: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/es.yml b/config/locales/crowdin/es.yml index d45ff55aa46..2b50265d225 100644 --- a/config/locales/crowdin/es.yml +++ b/config/locales/crowdin/es.yml @@ -107,9 +107,8 @@ es: trial: "Prueba" jemalloc_allocator: Asignador de memoria Jemalloc journal_aggregation: - explanation: - text: "Las acciones individuales de un usuario (como actualizar dos veces un paquete de trabajo) se combinan en una sola acción si la diferencia de antigüedad es inferior al intervalo de tiempo especificado. Se mostrarán como una sola acción en la aplicación. También se retrasarán las notificaciones por la misma cantidad de tiempo, lo que reducirá el número de correos electrónicos enviados y causará también que se retrase el %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Importar" jira: diff --git a/config/locales/crowdin/et.yml b/config/locales/crowdin/et.yml index 0ea40e65a4d..738a01823a4 100644 --- a/config/locales/crowdin/et.yml +++ b/config/locales/crowdin/et.yml @@ -107,9 +107,8 @@ et: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/eu.yml b/config/locales/crowdin/eu.yml index 1df0dc21975..e3b12c424ef 100644 --- a/config/locales/crowdin/eu.yml +++ b/config/locales/crowdin/eu.yml @@ -107,9 +107,8 @@ eu: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/fa.yml b/config/locales/crowdin/fa.yml index 5f646211b46..812fbdc4f9e 100644 --- a/config/locales/crowdin/fa.yml +++ b/config/locales/crowdin/fa.yml @@ -107,9 +107,8 @@ fa: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/fi.yml b/config/locales/crowdin/fi.yml index 47567ef5f29..35b82a62e43 100644 --- a/config/locales/crowdin/fi.yml +++ b/config/locales/crowdin/fi.yml @@ -107,9 +107,8 @@ fi: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/fil.yml b/config/locales/crowdin/fil.yml index afc0c11f0ee..12b3b1603a5 100644 --- a/config/locales/crowdin/fil.yml +++ b/config/locales/crowdin/fil.yml @@ -107,9 +107,8 @@ fil: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/fr.yml b/config/locales/crowdin/fr.yml index 51b406f0400..d745569c87e 100644 --- a/config/locales/crowdin/fr.yml +++ b/config/locales/crowdin/fr.yml @@ -107,9 +107,8 @@ fr: trial: "Essai" jemalloc_allocator: Allocateur de mémoire Jemalloc journal_aggregation: - explanation: - text: "Les actions individuelles d'un utilisateur (par ex. un lot de travaux mis à jour deux fois) sont agrégées en une seule action si leur différence d'âge est inférieure à la période spécifiée. Elles seront affichées en une seule action dans l'application. Cela retardera également les notifications du même temps réduisant donc le nombre d'e-mails envoyés et affectera également le délai %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Importation" jira: diff --git a/config/locales/crowdin/he.yml b/config/locales/crowdin/he.yml index 2a8d5793c82..b1940bb79bf 100644 --- a/config/locales/crowdin/he.yml +++ b/config/locales/crowdin/he.yml @@ -107,9 +107,8 @@ he: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/hi.yml b/config/locales/crowdin/hi.yml index 637978a7ecc..4395a65798e 100644 --- a/config/locales/crowdin/hi.yml +++ b/config/locales/crowdin/hi.yml @@ -107,9 +107,8 @@ hi: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/hr.yml b/config/locales/crowdin/hr.yml index f4589800279..4542c71125c 100644 --- a/config/locales/crowdin/hr.yml +++ b/config/locales/crowdin/hr.yml @@ -107,9 +107,8 @@ hr: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/hu.yml b/config/locales/crowdin/hu.yml index cece59e0577..e93409dfc06 100644 --- a/config/locales/crowdin/hu.yml +++ b/config/locales/crowdin/hu.yml @@ -107,9 +107,8 @@ hu: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator journal_aggregation: - explanation: - text: "A felhasználó egyes műveletei (pl. egy munkacsomag kétszeri frissítése) egyetlen műveletté egyesülnek, ha korkülönbségük kisebb, mint a megadott időtartam. Ezek egyetlen műveletként jelennek meg az alkalmazásban. Ez ugyanannyi idővel késlelteti az értesítéseket, csökkenti az elküldött emailek számát, valamint befolyásolja a %{webhook_link} késleltetését is.\n" - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/id.yml b/config/locales/crowdin/id.yml index 8e2facbad73..a7556fd428a 100644 --- a/config/locales/crowdin/id.yml +++ b/config/locales/crowdin/id.yml @@ -107,9 +107,8 @@ id: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator journal_aggregation: - explanation: - text: "Setiap tindakan pengguna (mis. memperbarui paket kerja dua kali) digabungkan menjadi satu tindakan jika perbedaan usianya kurang dari rentang waktu yang ditentukan. Mereka akan ditampilkan sebagai tindakan tunggal dalam aplikasi. Ini juga akan menunda pemberitahuan dengan jumlah waktu yang sama sehingga mengurangi jumlah email yang dikirim dan juga akan memengaruhi penundaan %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/it.yml b/config/locales/crowdin/it.yml index ceca5c46278..90c97d8dcce 100644 --- a/config/locales/crowdin/it.yml +++ b/config/locales/crowdin/it.yml @@ -107,9 +107,8 @@ it: trial: "Prova" jemalloc_allocator: Allocatore di memoria Jemalloc journal_aggregation: - explanation: - text: "Le singole azioni di un utente (es. l'aggiornamento di una macro-attività due volte) vengono aggregate in un'unica azione se il tempo intercorso tra esse è inferiore al periodo minimo di tempo impostato. Verranno visualizzate quindi come un'unica azione all'interno dell'applicazione. Questo ritarderà anche le notifiche della stessa quantità di tempo, riducendo così il numero di email inviate, e influirà anche sul ritardo di %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Importa" jira: diff --git a/config/locales/crowdin/ja.yml b/config/locales/crowdin/ja.yml index 2c8c1e99978..241e10bfefa 100644 --- a/config/locales/crowdin/ja.yml +++ b/config/locales/crowdin/ja.yml @@ -107,9 +107,8 @@ ja: trial: "試用版" jemalloc_allocator: Jemalloc メモリアロケータ journal_aggregation: - explanation: - text: "ユーザーの個々のアクション (例:ワークパッケージを2回更新する)は、指定された時間範囲よりも時間差が小さい場合、単一のアクションに集約されます。 これらはアプリケーション内で単一のアクションとして表示されます。 これにより、送信されるメールの数が減少し、 %{webhook_link} の遅延にも影響します。" - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/js-fr.yml b/config/locales/crowdin/js-fr.yml index d96d569ffdc..0cd20ffc988 100644 --- a/config/locales/crowdin/js-fr.yml +++ b/config/locales/crowdin/js-fr.yml @@ -993,7 +993,7 @@ fr: embedded_tab_disabled: "Cet onglet de configuration n'est pas disponible pour la vue intégrée que vous êtes en train de modifier." default: "défaut" display_settings: "Paramètres d'affichage" - default_mode: "Liste ombrée" + default_mode: "Liste" hierarchy_mode: "Hiérarchie" hierarchy_hint: "Tous les résultats du tableau filtrés seront augmentés de leurs ancêtres . Les hiérarchies peuvent être dépliées et repliées." display_sums_hint: "Afficher les sommes de tous les attributs sommables dans une ligne sous les résultats du tableau." diff --git a/config/locales/crowdin/ka.yml b/config/locales/crowdin/ka.yml index d51f236c710..c624053d5de 100644 --- a/config/locales/crowdin/ka.yml +++ b/config/locales/crowdin/ka.yml @@ -107,9 +107,8 @@ ka: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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: "ვებჰუკი" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/kk.yml b/config/locales/crowdin/kk.yml index 21bac966abc..68f8f5ab02a 100644 --- a/config/locales/crowdin/kk.yml +++ b/config/locales/crowdin/kk.yml @@ -107,9 +107,8 @@ kk: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ko.yml b/config/locales/crowdin/ko.yml index 0955c2ff059..ff189b93f3b 100644 --- a/config/locales/crowdin/ko.yml +++ b/config/locales/crowdin/ko.yml @@ -107,9 +107,8 @@ ko: trial: "평가판" jemalloc_allocator: Jemalloc 메모리 할당기 journal_aggregation: - explanation: - text: "사용자의 개별 작업(예: 작업 패키지를 두 번 업데이트)은 연령 차이가 지정된 기간 미만인 경우 단일 작업으로 집계됩니다. 애플리케이션 내에서 단일 작업으로 표시됩니다. 또한 이는 전송되는 이메일 수를 줄이는 동일한 시간만큼 알림을 지연시키고 %{webhook_link} 지연에도 영향을 미칩니다." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "가져오기" jira: diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index c55f12e9ef3..28a071700ee 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -107,9 +107,8 @@ lt: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator journal_aggregation: - explanation: - text: "Visi atskiri naudotojo veiksmai (t.y. darbų paketo atnaujinimas du kartus) yra sugrupuojami į vieną veiksmą, jei laiko tarpas tarp jų yra mažesnis už šį nustatymą. Programoje jie bus rodomi kaip vienas veiksmas. Tiek pat bus pavėlinti ir pranešimai. Dėl to sumažės siunčiamų el.laiškų skaičius ir taipogi įtakos %{webhook_link} delsimą." - link: "tinklo jungtis" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/lv.yml b/config/locales/crowdin/lv.yml index 54d64b72ece..5dac935c88e 100644 --- a/config/locales/crowdin/lv.yml +++ b/config/locales/crowdin/lv.yml @@ -107,9 +107,8 @@ lv: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/mn.yml b/config/locales/crowdin/mn.yml index fdbd5f0e667..98bb0981dd7 100644 --- a/config/locales/crowdin/mn.yml +++ b/config/locales/crowdin/mn.yml @@ -107,9 +107,8 @@ mn: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ms.yml b/config/locales/crowdin/ms.yml index 416f3e0122e..6f080f18f70 100644 --- a/config/locales/crowdin/ms.yml +++ b/config/locales/crowdin/ms.yml @@ -107,9 +107,8 @@ ms: trial: "Trial" jemalloc_allocator: Pengagih ingatan Jemalloc journal_aggregation: - explanation: - text: "Tindakan individu pengguna (cth. mengemas kini pakej kerja dua kali) dikumpulkan ke dalam satu tindakan tunggal jika perbezaan umur mereka kurang daripada tempoh masa yang ditetapkan. Mereka akan dipaparkan sebagai tindakan tunggal dalam aplikasi. Ini juga akan menangguhkan pemberitahuan dengan jumlah masa yang sama, mengurangkan bilangan e-mel yang dihantar serta akan memberi kesan kepada penagguhan %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ne.yml b/config/locales/crowdin/ne.yml index 3d523385d38..c2337ba94ea 100644 --- a/config/locales/crowdin/ne.yml +++ b/config/locales/crowdin/ne.yml @@ -107,9 +107,8 @@ ne: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/nl.yml b/config/locales/crowdin/nl.yml index 5c22ef74e12..aa24f6dc618 100644 --- a/config/locales/crowdin/nl.yml +++ b/config/locales/crowdin/nl.yml @@ -107,9 +107,8 @@ nl: trial: "Trial" jemalloc_allocator: Jemalloc geheugentoewijzer journal_aggregation: - explanation: - text: "Individuele acties van een gebruiker (bijv. het bijwerken van een werkpakket twee keer) wordt samengevoegd tot een enkele actie als hun leeftijdverschil minder is dan de aangegeven timespat. Ze worden weergegeven als een enkele actie binnen de applicatie. Dit zal ook meldingen vertragen met dezelfde tijd die het aantal verstuurde e-mails vermindert en zal ook %{webhook_link} vertraging beïnvloeden." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/no.yml b/config/locales/crowdin/no.yml index 94dbfd1c93f..2489cfc4eb8 100644 --- a/config/locales/crowdin/no.yml +++ b/config/locales/crowdin/no.yml @@ -107,9 +107,8 @@ trial: "Trial" jemalloc_allocator: Jemalloc minne allokator journal_aggregation: - explanation: - text: "Individuelle handlinger av en bruker (f.eks. oppdatering av en arbeidspakke to ganger) aggregeres til en enkelt handling hvis aldersforskjellen er mindre enn det spesifiserte tidsrommet. De vil bli vist som en enkelt handling i programmet. Dette vil også forsinke varslinger med samme tidsperiode som reduserer antall e-poster som sendes, og vil også påvirke %{webhook_link} forsinkelse." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/pl.yml b/config/locales/crowdin/pl.yml index 69769b2c645..09351122e62 100644 --- a/config/locales/crowdin/pl.yml +++ b/config/locales/crowdin/pl.yml @@ -107,9 +107,8 @@ pl: trial: "Wersja próbna" jemalloc_allocator: Alokator pamięci Jemalloc journal_aggregation: - explanation: - text: "Indywidualne działania użytkownika (np. dwukrotna aktualizacja pakietu roboczego) są agregowane w jedno działanie, jeśli różnica czasowa między nimi jest mniejsza niż określony przedział czasowy. Będą one wyświetlane jako jedno działanie w aplikacji. Spowoduje to również opóźnienie powiadomień o tę samą ilość czasu, zmniejszając liczbę wysyłanych wiadomości e-mail i wpłynie też na opóźnienie %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index 40d56f96df2..bf4649be96f 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -107,9 +107,8 @@ pt-BR: trial: "Avaliação" jemalloc_allocator: Alocador de memória Jemalloc journal_aggregation: - explanation: - text: "As ações individuais de um usuário (por exemplo, atualizar um pacote de trabalho duas vezes) são agregadas em uma única ação se o intervalo de tempo for menor que o intervalo especificado. Eles serão exibidos como uma única ação dentro do aplicativo. Isso também atrasará as notificações no mesmo intervalo de tempo, reduzindo o número de e-mails enviados e também afetará o atraso de %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Importar" jira: diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index cb776a3c66f..b88d3df2882 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -107,9 +107,8 @@ pt-PT: trial: "Teste" jemalloc_allocator: Alocador de memória Jemalloc journal_aggregation: - explanation: - text: "As ações individuais de um utilizador (por exemplo, atualizar um pacote de trabalho duas vezes) são agregadas numa única ação se a sua diferença de idade for menor que o intervalo de tempo especificado. Serão mostradas como uma única ação dentro da aplicação. Também vai atrasar as notificações pelo mesmo período de tempo, o que reduz o número de e-mails enviados, e afeta ainda o atraso de %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Importar" jira: diff --git a/config/locales/crowdin/ro.yml b/config/locales/crowdin/ro.yml index 252e371b15b..7f1188093a3 100644 --- a/config/locales/crowdin/ro.yml +++ b/config/locales/crowdin/ro.yml @@ -107,9 +107,8 @@ ro: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator journal_aggregation: - explanation: - text: "Acțiunile individuale ale unui utilizator (de exemplu, actualizarea de două ori a unui pachet de lucru) sunt agregate într-o singură acțiune dacă diferența de vechime dintre ele este mai mică decât intervalul de timp specificat. Acestea vor fi afișate ca o singură acțiune în cadrul aplicației. De asemenea, acest lucru va întârzia notificările cu aceeași perioadă de timp, reducând numărul de e-mailuri trimise și va afecta, de asemenea, întârzierea %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index d20033da957..4f678316e71 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -107,9 +107,8 @@ ru: trial: "Пробная версия" jemalloc_allocator: Распределитель памяти Jemalloc journal_aggregation: - explanation: - text: "Личные действия пользователя (например, обновление пакета работ дважды) агрегируются в одно действие, если их разница во времени не больше указанной. Они будут отображаться как одно действие внутри приложения. Это также задерживает уведомление на такое же количество времени, уменьшая количество отправляемых писем и также повлияет на %{webhook_link} задержки." - link: "вебхук" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Импорт" jira: diff --git a/config/locales/crowdin/rw.yml b/config/locales/crowdin/rw.yml index 227b057148c..91cdb747bea 100644 --- a/config/locales/crowdin/rw.yml +++ b/config/locales/crowdin/rw.yml @@ -107,9 +107,8 @@ rw: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/si.yml b/config/locales/crowdin/si.yml index 63170be5a53..89041da5b78 100644 --- a/config/locales/crowdin/si.yml +++ b/config/locales/crowdin/si.yml @@ -107,9 +107,8 @@ si: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/sk.yml b/config/locales/crowdin/sk.yml index 765475b7116..0cbbb3a78a9 100644 --- a/config/locales/crowdin/sk.yml +++ b/config/locales/crowdin/sk.yml @@ -107,9 +107,8 @@ sk: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/sl.yml b/config/locales/crowdin/sl.yml index 6674500245c..0fea188e4fc 100644 --- a/config/locales/crowdin/sl.yml +++ b/config/locales/crowdin/sl.yml @@ -107,9 +107,8 @@ sl: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/sr.yml b/config/locales/crowdin/sr.yml index f2480277273..613a55c8318 100644 --- a/config/locales/crowdin/sr.yml +++ b/config/locales/crowdin/sr.yml @@ -107,9 +107,8 @@ sr: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/sv.yml b/config/locales/crowdin/sv.yml index 2294b0ff2d2..716c6f87074 100644 --- a/config/locales/crowdin/sv.yml +++ b/config/locales/crowdin/sv.yml @@ -107,9 +107,8 @@ sv: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/th.yml b/config/locales/crowdin/th.yml index 98e7cf6183b..afe6747ad9f 100644 --- a/config/locales/crowdin/th.yml +++ b/config/locales/crowdin/th.yml @@ -107,9 +107,8 @@ th: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/tr.yml b/config/locales/crowdin/tr.yml index c405fda23ec..80d7341f7ab 100644 --- a/config/locales/crowdin/tr.yml +++ b/config/locales/crowdin/tr.yml @@ -107,9 +107,8 @@ tr: trial: "Deneme" jemalloc_allocator: Jemalloc bellek ayırıcı journal_aggregation: - explanation: - text: "Bir kullanıcının bireysel eylemleri (örneğin, bir iş paketini iki kez güncelleme), yaş farkı belirtilen zaman aralığından azsa tek bir eylemde toplanır. Uygulama içinde tek bir eylem olarak görüntülenecektir. Bu aynı zamanda gönderilen e-posta sayısını azaltarak bildirimleri aynı süre kadar geciktirecek ve ayrıca %{webhook_link} gecikmesini etkileyecektir." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/uk.yml b/config/locales/crowdin/uk.yml index 0d277d48485..4b29cda5494 100644 --- a/config/locales/crowdin/uk.yml +++ b/config/locales/crowdin/uk.yml @@ -107,9 +107,8 @@ uk: trial: "Пробний період" jemalloc_allocator: Розподіл пам'яті Jemalloc journal_aggregation: - explanation: - text: "Окремі дії користувача (напр., оновлення робочого пакета двічі) зводяться в одну дію, якщо відмінність у часі між ними менша за вказаний проміжок часу. Їх буде виведено як окремі дії в межах додатка. Крім того, це призведе до затримки сповіщень на такий самий проміжок часу, що зменшить кількість електронних листів, які надсилатимуться, а також вплине на затримку %{webhook_link}." - link: "вебгука" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Iмпорт" jira: diff --git a/config/locales/crowdin/uz.yml b/config/locales/crowdin/uz.yml index 8036f5629e9..44bca1eb376 100644 --- a/config/locales/crowdin/uz.yml +++ b/config/locales/crowdin/uz.yml @@ -107,9 +107,8 @@ uz: trial: "Trial" jemalloc_allocator: Jemalloc memory allocator 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" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/vi.yml b/config/locales/crowdin/vi.yml index ae89da48a3b..6960a6b315b 100644 --- a/config/locales/crowdin/vi.yml +++ b/config/locales/crowdin/vi.yml @@ -107,9 +107,8 @@ vi: trial: "thử nghiệm" jemalloc_allocator: Bộ cấp phát bộ nhớ Jemalloc journal_aggregation: - explanation: - text: "Các hành động riêng lẻ của người dùng (ví dụ: cập nhật gói công việc hai lần) được tổng hợp thành một hành động nếu chênh lệch tuổi tác của họ nhỏ hơn khoảng thời gian đã chỉ định. Chúng sẽ được hiển thị dưới dạng một hành động duy nhất trong ứng dụng. Điều này cũng sẽ làm chậm thông báo với cùng một khoảng thời gian làm giảm số lượng email được gửi và cũng sẽ ảnh hưởng đến độ trễ %{webhook_link}." - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/config/locales/crowdin/zh-CN.yml b/config/locales/crowdin/zh-CN.yml index 47ba8d7fdcf..ce709990d47 100644 --- a/config/locales/crowdin/zh-CN.yml +++ b/config/locales/crowdin/zh-CN.yml @@ -107,9 +107,8 @@ zh-CN: trial: "试用" jemalloc_allocator: 使用 jemalloc 内存分配器 journal_aggregation: - explanation: - text: "如果用户的多项操作(例如,更新工作包两次)的时间间隔小于指定的时间跨度,则这些操作将被聚合为单个操作,并在应用程序中显示为单个操作。这也会将通知延迟同等的时间,从而减少电子邮件的发送数量,并且还会影响 %{webhook_link} 延迟。" - link: "Webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "导入" jira: diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index c5b088ffaac..19990d2f362 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -107,9 +107,8 @@ zh-TW: trial: "試用" jemalloc_allocator: Jemalloc 記憶體分配器 journal_aggregation: - explanation: - text: "使用者的個別操作(例如:在短時間內更新同一工作套件兩次)若時間差距小於指定時長,系統會將這些操作合併為單一動作,並於應用程式中以單一動作顯示。\n此機制同時會延遲通知發送相同的時間,以減少電子郵件數量,並且會影響 %{webhook_link} 的延遲時間。" - link: "webhook" + caption: > + 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 the [webhook](webhook_link) delay. import: title: "Import" jira: diff --git a/modules/budgets/config/locales/crowdin/cs.yml b/modules/budgets/config/locales/crowdin/cs.yml index 765bb62a1c2..c3ec09e6661 100644 --- a/modules/budgets/config/locales/crowdin/cs.yml +++ b/modules/budgets/config/locales/crowdin/cs.yml @@ -71,9 +71,9 @@ cs: total_actual_costs: "Total actual costs" total_planned_budget: "Total planned budget" budget_by_cost_type: - title: "Budget by cost type" + title: "Rozpočet podle typu nákladů" blankslate: - heading: "Start project controlling" + heading: "Zahájit projektový controlling" description: "Get an overview of your budgets and costs to efficiently track the health status of your project" blankslate_zero: heading: "Budget details missing" diff --git a/modules/costs/config/locales/crowdin/cs.yml b/modules/costs/config/locales/crowdin/cs.yml index 85d249a63ce..139bd7778c9 100644 --- a/modules/costs/config/locales/crowdin/cs.yml +++ b/modules/costs/config/locales/crowdin/cs.yml @@ -31,9 +31,9 @@ cs: spent: "Strávený čas" spent_on: "Datum" logged_by: "Zadáno" - entity: Přihlášen - entity_id: Přihlášen - entity_gid: Přihlášen + entity: Přihlášen + entity_id: Přihlášen + entity_gid: Přihlášen cost_type: unit: "Název jednotky" unit_plural: "Název Pluralizované jednotky" @@ -61,9 +61,9 @@ cs: start_time: Čas zahájení end_time: Čas dokončení time: Čas - entity: Přihlášen - entity_id: Přihlášen - entity_gid: Přihlášen + entity: Přihlášen + entity_id: Přihlášen + entity_gid: Přihlášen models: time_entry: one: "Vstup času" @@ -242,8 +242,8 @@ cs: actual_costs: title: "Actual costs by month" blankslate: - heading: "Start tracking your time and costs" - description: "Get an overview of your costs and logged time to monitor progress of your project. Make sure that work packages are associated with the correct budget." + heading: "Začněte sledovat svůj čas a náklady" + description: "Získejte přehled o vašich nákladech a zalogovaném čase pro sledování průběhu vašeho projektu. Ujistěte se, že pracovní balíčky jsou přiřazeny ke správnému rozpočtu." action: "Log time" view_details: "View actual costs details" ee: diff --git a/modules/documents/config/locales/crowdin/cs.yml b/modules/documents/config/locales/crowdin/cs.yml index 3fe467eb53a..0151e949282 100644 --- a/modules/documents/config/locales/crowdin/cs.yml +++ b/modules/documents/config/locales/crowdin/cs.yml @@ -78,7 +78,7 @@ cs: description: "The connection to the real-time text collaboration server has been restored." tabs: "Document tabs" index_page: - name: "Název:" + name: "Název" type: "Typ" updated_at: "Last edited" label_legacy: "Legacy" diff --git a/modules/grids/config/locales/crowdin/cs.yml b/modules/grids/config/locales/crowdin/cs.yml index 6bc4da8bcac..e9165943e7d 100644 --- a/modules/grids/config/locales/crowdin/cs.yml +++ b/modules/grids/config/locales/crowdin/cs.yml @@ -5,12 +5,12 @@ cs: empty: "This widget is currently empty." not_available: "This widget is not available." subitems: - title: "Subitems" + title: "Dílčí položky" no_results: "There are no visible children." view_all_subitems: "View all subitems" - button_text: "Subitem" + button_text: "Dílčí položky" members: - title: "Members" + title: "Členové" no_results: "Žádní viditelní členové." view_all_members: "Zobrazit všechny členy" show_members_count: "Show all %{count} members" diff --git a/modules/grids/config/locales/crowdin/js-cs.yml b/modules/grids/config/locales/crowdin/js-cs.yml index a24501fd1df..5f2d3fd11c2 100644 --- a/modules/grids/config/locales/crowdin/js-cs.yml +++ b/modules/grids/config/locales/crowdin/js-cs.yml @@ -29,7 +29,7 @@ cs: finished: 'Dokončeno' discontinued: 'Zrušeno' subprojects: - title: 'Subitems' + title: 'Dílčí položky' project_favorites: title: 'Oblíbené projekty' no_results: 'Momentálně nemáte žádné oblíbené projekty. Klikněte na ikonu hvězdičky v nástěnce projektu pro přidání jednoho do oblíbených.' diff --git a/modules/meeting/config/locales/crowdin/cs.yml b/modules/meeting/config/locales/crowdin/cs.yml index a335c7f279e..9b7169ccdb0 100644 --- a/modules/meeting/config/locales/crowdin/cs.yml +++ b/modules/meeting/config/locales/crowdin/cs.yml @@ -221,7 +221,7 @@ cs: text: template: "Tito účastníci budou automaticky pozváni na všechna budoucí zasedání po jejich vytvoření." manage_participants: "Search for and add project members as participants to this meeting." - search_for_members: "Search for project members" + search_for_members: "Vyhledávání členů projektu" blankslate: heading: "Nikdo tu není" description: "There are no participants yet." diff --git a/modules/meeting/config/locales/crowdin/fr.yml b/modules/meeting/config/locales/crowdin/fr.yml index 3409978e1d7..c296d40bbb4 100644 --- a/modules/meeting/config/locales/crowdin/fr.yml +++ b/modules/meeting/config/locales/crowdin/fr.yml @@ -85,7 +85,7 @@ fr: models: recurring_meeting: "Réunion récurrente" meeting: "Réunion ponctuelle" - meeting_agenda_item: "" + meeting_agenda_item: "Point d'ordre du jour" meeting_agenda: "Ordre du jour" meeting_section: "Section" token/ical_meeting: diff --git a/modules/storages/config/locales/crowdin/af.yml b/modules/storages/config/locales/crowdin/af.yml index 79cc3a1cece..b28bb59ee62 100644 --- a/modules/storages/config/locales/crowdin/af.yml +++ b/modules/storages/config/locales/crowdin/af.yml @@ -279,6 +279,8 @@ af: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ af: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ar.yml b/modules/storages/config/locales/crowdin/ar.yml index 1426a11e5c8..748f12662da 100644 --- a/modules/storages/config/locales/crowdin/ar.yml +++ b/modules/storages/config/locales/crowdin/ar.yml @@ -279,6 +279,8 @@ ar: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -330,8 +332,10 @@ ar: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/az.yml b/modules/storages/config/locales/crowdin/az.yml index abb249dc335..99a1caa5947 100644 --- a/modules/storages/config/locales/crowdin/az.yml +++ b/modules/storages/config/locales/crowdin/az.yml @@ -279,6 +279,8 @@ az: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ az: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/be.yml b/modules/storages/config/locales/crowdin/be.yml index 47a23120fcb..8bd71dbceb2 100644 --- a/modules/storages/config/locales/crowdin/be.yml +++ b/modules/storages/config/locales/crowdin/be.yml @@ -279,6 +279,8 @@ be: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -326,8 +328,10 @@ be: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/bg.yml b/modules/storages/config/locales/crowdin/bg.yml index a4b66915d64..1f7e130df70 100644 --- a/modules/storages/config/locales/crowdin/bg.yml +++ b/modules/storages/config/locales/crowdin/bg.yml @@ -279,6 +279,8 @@ bg: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ bg: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ca.yml b/modules/storages/config/locales/crowdin/ca.yml index aec10002a86..a06891cbc2e 100644 --- a/modules/storages/config/locales/crowdin/ca.yml +++ b/modules/storages/config/locales/crowdin/ca.yml @@ -279,6 +279,8 @@ ca: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ ca: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ckb-IR.yml b/modules/storages/config/locales/crowdin/ckb-IR.yml index 8d7ce889f10..813a87713e2 100644 --- a/modules/storages/config/locales/crowdin/ckb-IR.yml +++ b/modules/storages/config/locales/crowdin/ckb-IR.yml @@ -279,6 +279,8 @@ ckb-IR: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ ckb-IR: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/cs.yml b/modules/storages/config/locales/crowdin/cs.yml index 838a06f31ab..052866f7691 100644 --- a/modules/storages/config/locales/crowdin/cs.yml +++ b/modules/storages/config/locales/crowdin/cs.yml @@ -279,6 +279,8 @@ cs: drive_contents: Drive content files_request: Fetching team folder files header: Automaticky spravované projektové složky + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -326,8 +328,10 @@ cs: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Nakonfigurované heslo aplikace je neplatné. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/da.yml b/modules/storages/config/locales/crowdin/da.yml index bdc24e0919f..90153e877a4 100644 --- a/modules/storages/config/locales/crowdin/da.yml +++ b/modules/storages/config/locales/crowdin/da.yml @@ -279,6 +279,8 @@ da: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ da: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/de.yml b/modules/storages/config/locales/crowdin/de.yml index e15f8ff11e6..52a0cf0a924 100644 --- a/modules/storages/config/locales/crowdin/de.yml +++ b/modules/storages/config/locales/crowdin/de.yml @@ -279,6 +279,8 @@ de: drive_contents: Speicherinhalt files_request: Teamordnerdateien werden abgerufen header: Automatisch verwaltete Projektordner + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Abhängigkeit: Teamordner' team_folder_contents: Inhalt des Teamordners team_folder_presence: Teamordner existiert @@ -322,8 +324,10 @@ de: nc_oauth_request_not_found: Der Endpunkt für den Abruf des aktuell verbundenen Benutzers wurde nicht gefunden. Bitte überprüfen Sie die Serverprotokolle für weitere Informationen. nc_oauth_request_unauthorized: Der aktuelle Benutzer ist nicht berechtigt, auf den Remote-Datei-Speicher zuzugreifen. Bitte überprüfen Sie die Server-Protokolle für weitere Informationen. nc_oauth_token_missing: OpenProject kann die Kommunikation auf Benutzerebene mit Nextcloud nicht testen, da der Benutzer sein Nextcloud Konto noch nicht verknüpft hat. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Der Teamordner konnte nicht gefunden werden. - nc_unexpected_content: Unerwarteter Inhalt im verwalteten Teamordner gefunden. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Das konfigurierte App-Passwort ist ungültig. not_configured: Die Verbindung konnte nicht validiert werden. Bitte schließen Sie zuerst die Konfiguration ab. od_client_cant_delete_folder: Der Client hat Probleme beim Löschen von Ordnern. Bitte überprüfen Sie die Setup-Dokumentation für Ihren Speicher. diff --git a/modules/storages/config/locales/crowdin/el.yml b/modules/storages/config/locales/crowdin/el.yml index c1cea2088f2..12b2669b557 100644 --- a/modules/storages/config/locales/crowdin/el.yml +++ b/modules/storages/config/locales/crowdin/el.yml @@ -279,6 +279,8 @@ el: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ el: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/eo.yml b/modules/storages/config/locales/crowdin/eo.yml index 070c06456f2..6f0ad947e98 100644 --- a/modules/storages/config/locales/crowdin/eo.yml +++ b/modules/storages/config/locales/crowdin/eo.yml @@ -279,6 +279,8 @@ eo: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ eo: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/es.yml b/modules/storages/config/locales/crowdin/es.yml index 61a3a316e45..5d8e0c2e60b 100644 --- a/modules/storages/config/locales/crowdin/es.yml +++ b/modules/storages/config/locales/crowdin/es.yml @@ -279,6 +279,8 @@ es: drive_contents: Contenido de la unidad files_request: Obteniendo archivos de carpetas de equipo header: Carpetas de proyecto gestionadas automáticamente + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependencia: Carpetas de equipo' team_folder_contents: Contenido de la carpeta de equipo team_folder_presence: La carpeta del equipo existe @@ -322,8 +324,10 @@ es: nc_oauth_request_not_found: No se ha encontrado el terminal para obtener el usuario actualmente conectado. Consulte los registros del servidor para obtener más información. nc_oauth_request_unauthorized: El usuario actual no está autorizado a acceder al almacenamiento remoto de archivos. Consulte los registros del servidor para obtener más información. nc_oauth_token_missing: OpenProject no puede probar la comunicación a nivel de usuario con Nextcloud, ya que el usuario aún no ha vinculado su cuenta de Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: No se ha podido encontrar la carpeta del equipo. - nc_unexpected_content: Se ha encontrado contenido inesperado en la carpeta del equipo gestionada. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: La contraseña configurada de la aplicación no es válida. not_configured: No se ha podido validar la conexión. Por favor, termine primero la configuración. od_client_cant_delete_folder: El cliente tiene problemas para eliminar carpetas. Consulte la documentación de configuración de su almacenamiento. diff --git a/modules/storages/config/locales/crowdin/et.yml b/modules/storages/config/locales/crowdin/et.yml index b1f956d4b8a..04e14a57ece 100644 --- a/modules/storages/config/locales/crowdin/et.yml +++ b/modules/storages/config/locales/crowdin/et.yml @@ -279,6 +279,8 @@ et: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ et: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/eu.yml b/modules/storages/config/locales/crowdin/eu.yml index fcd17fadb08..71b214592a8 100644 --- a/modules/storages/config/locales/crowdin/eu.yml +++ b/modules/storages/config/locales/crowdin/eu.yml @@ -279,6 +279,8 @@ eu: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ eu: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/fa.yml b/modules/storages/config/locales/crowdin/fa.yml index 79230b765b8..345e54afedd 100644 --- a/modules/storages/config/locales/crowdin/fa.yml +++ b/modules/storages/config/locales/crowdin/fa.yml @@ -279,6 +279,8 @@ fa: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ fa: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/fi.yml b/modules/storages/config/locales/crowdin/fi.yml index c51baabbf50..d03517b3564 100644 --- a/modules/storages/config/locales/crowdin/fi.yml +++ b/modules/storages/config/locales/crowdin/fi.yml @@ -279,6 +279,8 @@ fi: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ fi: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/fil.yml b/modules/storages/config/locales/crowdin/fil.yml index 19ba729d6d3..e4313441255 100644 --- a/modules/storages/config/locales/crowdin/fil.yml +++ b/modules/storages/config/locales/crowdin/fil.yml @@ -279,6 +279,8 @@ fil: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ fil: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/fr.yml b/modules/storages/config/locales/crowdin/fr.yml index 67723cacaca..0bd06148587 100644 --- a/modules/storages/config/locales/crowdin/fr.yml +++ b/modules/storages/config/locales/crowdin/fr.yml @@ -279,6 +279,8 @@ fr: drive_contents: Contenu du lecteur files_request: Récupération des fichiers du dossier de l'équipe header: Dossiers de projets gérés automatiquement + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dépendance : dossiers d''équipe' team_folder_contents: Contenu du dossier de l'équipe team_folder_presence: Le dossier de l'équipe existe @@ -322,8 +324,10 @@ fr: nc_oauth_request_not_found: Le point de terminaison pour récupérer l'utilisateur actuellement connecté n'a pas été trouvé. Veuillez vérifier les journaux du serveur pour obtenir plus d'informations. nc_oauth_request_unauthorized: L'utilisateur actuel n'est pas autorisé à accéder à l'espace de stockage de fichiers distant. Veuillez consulter les journaux du serveur pour obtenir plus d'informations. nc_oauth_token_missing: OpenProject ne peut pas tester la communication au niveau utilisateur avec Nextcloud, car l'utilisateur n'a pas encore lié son compte Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Le dossier de l'équipe est introuvable. - nc_unexpected_content: Contenu inattendu trouvé dans le dossier d'équipe géré. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Le mot de passe de l'application configurée n'est pas valide. not_configured: La connexion n'a pas pu être validée. Veuillez d'abord terminer la configuration. od_client_cant_delete_folder: Le client rencontre des difficultés pour supprimer des dossiers. Veuillez consulter la documentation d'installation de votre espace de stockage. diff --git a/modules/storages/config/locales/crowdin/he.yml b/modules/storages/config/locales/crowdin/he.yml index 09c2a793d07..ef759a21e2d 100644 --- a/modules/storages/config/locales/crowdin/he.yml +++ b/modules/storages/config/locales/crowdin/he.yml @@ -279,6 +279,8 @@ he: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -326,8 +328,10 @@ he: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/hi.yml b/modules/storages/config/locales/crowdin/hi.yml index fc4394b44d5..297c71b0cc3 100644 --- a/modules/storages/config/locales/crowdin/hi.yml +++ b/modules/storages/config/locales/crowdin/hi.yml @@ -279,6 +279,8 @@ hi: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ hi: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/hr.yml b/modules/storages/config/locales/crowdin/hr.yml index 62fb37019f5..f10343005e9 100644 --- a/modules/storages/config/locales/crowdin/hr.yml +++ b/modules/storages/config/locales/crowdin/hr.yml @@ -279,6 +279,8 @@ hr: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -324,8 +326,10 @@ hr: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/hu.yml b/modules/storages/config/locales/crowdin/hu.yml index 650320d6d38..45366821369 100644 --- a/modules/storages/config/locales/crowdin/hu.yml +++ b/modules/storages/config/locales/crowdin/hu.yml @@ -279,6 +279,8 @@ hu: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ hu: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/id.yml b/modules/storages/config/locales/crowdin/id.yml index 1bec965aa48..41a4cd5d2b9 100644 --- a/modules/storages/config/locales/crowdin/id.yml +++ b/modules/storages/config/locales/crowdin/id.yml @@ -279,6 +279,8 @@ id: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -320,8 +322,10 @@ id: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/it.yml b/modules/storages/config/locales/crowdin/it.yml index edc56765089..a8d4059080f 100644 --- a/modules/storages/config/locales/crowdin/it.yml +++ b/modules/storages/config/locales/crowdin/it.yml @@ -279,6 +279,8 @@ it: drive_contents: Contenuto dell'unità files_request: Recupero dei file delle cartelle di team header: Cartelle di progetto gestite automaticamente + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dipendenza: Cartelle di team' team_folder_contents: Contenuto della cartella di team team_folder_presence: La cartella di team esiste @@ -322,8 +324,10 @@ it: nc_oauth_request_not_found: L'endpoint per recuperare l'utente attualmente connesso non è stato trovato. Per ulteriori informazioni, consulta i log del server. nc_oauth_request_unauthorized: L'utente attuale non è autorizzato ad accedere all'archivio file remoto. Per ulteriori informazioni, consulta i log del server. nc_oauth_token_missing: OpenProject non può testare la comunicazione a livello utente con Nextcloud poiché l'utente non ha ancora collegato il proprio account Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Impossibile trovare la cartella di team. - nc_unexpected_content: Contenuto inatteso trovato nella cartella di team gestita. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: La password dell'app configurata non è valida. not_configured: Non è stato possibile verificare la connessione. Prima è necessario completare la configurazione. od_client_cant_delete_folder: Il cliente riscontra problemi con l'eliminazione delle cartelle. Consulta la documentazione di configurazione del tuo archivio. diff --git a/modules/storages/config/locales/crowdin/ja.yml b/modules/storages/config/locales/crowdin/ja.yml index 5fc79945e20..3b9839f81ab 100644 --- a/modules/storages/config/locales/crowdin/ja.yml +++ b/modules/storages/config/locales/crowdin/ja.yml @@ -279,6 +279,8 @@ ja: drive_contents: Drive content files_request: Fetching team folder files header: 自動的に管理されるプロジェクトフォルダ + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -320,8 +322,10 @@ ja: nc_oauth_request_not_found: 現在接続しているユーザーを取得するエンドポイントが見つかりませんでした。詳細については、サーバーのログを確認してください。 nc_oauth_request_unauthorized: 現在のユーザーにはリモートファイルストレージにアクセスする権限がありません。サーバーのログを確認してください。 nc_oauth_token_missing: OpenProject は、Nextcloudアカウントへのリンクがまだないため、Nextcloudとのユーザーレベルの通信をテストできません。 + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: 設定されているアプリのパスワードが無効です。 not_configured: 接続を検証できませんでした。先に設定を完了してください。 od_client_cant_delete_folder: クライアントがフォルダを削除できません。ストレージのセットアップドキュメントを確認してください。 diff --git a/modules/storages/config/locales/crowdin/ka.yml b/modules/storages/config/locales/crowdin/ka.yml index c9a1c5f6871..c5fbcf72786 100644 --- a/modules/storages/config/locales/crowdin/ka.yml +++ b/modules/storages/config/locales/crowdin/ka.yml @@ -279,6 +279,8 @@ ka: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ ka: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/kk.yml b/modules/storages/config/locales/crowdin/kk.yml index b5598b3c0de..44e49d37233 100644 --- a/modules/storages/config/locales/crowdin/kk.yml +++ b/modules/storages/config/locales/crowdin/kk.yml @@ -279,6 +279,8 @@ kk: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ kk: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ko.yml b/modules/storages/config/locales/crowdin/ko.yml index e9e83ebae83..5740e8dfe55 100644 --- a/modules/storages/config/locales/crowdin/ko.yml +++ b/modules/storages/config/locales/crowdin/ko.yml @@ -279,6 +279,8 @@ ko: drive_contents: 드라이브 콘텐츠 files_request: 팀 폴더 파일 가져오기 header: 자동으로 관리되는 프로젝트 폴더 + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: '종속성: 팀 폴더' team_folder_contents: 팀 폴더 콘텐츠 team_folder_presence: 팀 폴더가 존재합니다 @@ -320,8 +322,10 @@ ko: nc_oauth_request_not_found: 현재 연결된 사용자를 가져올 엔드포인트를 찾을 수 없습니다. 자세한 내용은 서버 로그를 확인하세요. nc_oauth_request_unauthorized: 현재 사용자는 원격 파일 저장소에 액세스할 수 있는 권한이 없습니다. 자세한 내용은 서버 로그를 확인하세요. nc_oauth_token_missing: 사용자가 아직 Nextcloud 계정을 링크하지 않았기 때문에 OpenProject가 Nextcloud와의 사용자 수준 통신을 테스트할 수 없습니다. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: 팀 폴더를 찾을 수 없습니다. - nc_unexpected_content: 관리되는 팀 폴더에서 예기치 않은 콘텐츠가 발견되었습니다. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: 구성된 앱 암호가 잘못되었습니다. not_configured: 연결에 대한 유효성 검사를 할 수 없습니다. 먼저 구성을 완료하세요. od_client_cant_delete_folder: 클라이언트에서 폴더를 삭제하는 중에 문제가 발생했습니다. 저장소에 대한 설정 설명서를 확인하세요. diff --git a/modules/storages/config/locales/crowdin/lt.yml b/modules/storages/config/locales/crowdin/lt.yml index 74a6941abb8..8d2fffc5287 100644 --- a/modules/storages/config/locales/crowdin/lt.yml +++ b/modules/storages/config/locales/crowdin/lt.yml @@ -279,6 +279,8 @@ lt: drive_contents: Drive content files_request: Fetching team folder files header: Automatiškai valdomi projekto aplankai + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -326,8 +328,10 @@ lt: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/lv.yml b/modules/storages/config/locales/crowdin/lv.yml index caf10804681..df157562a29 100644 --- a/modules/storages/config/locales/crowdin/lv.yml +++ b/modules/storages/config/locales/crowdin/lv.yml @@ -279,6 +279,8 @@ lv: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -324,8 +326,10 @@ lv: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/mn.yml b/modules/storages/config/locales/crowdin/mn.yml index 388dfd99fc3..500878f5d56 100644 --- a/modules/storages/config/locales/crowdin/mn.yml +++ b/modules/storages/config/locales/crowdin/mn.yml @@ -279,6 +279,8 @@ mn: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ mn: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ms.yml b/modules/storages/config/locales/crowdin/ms.yml index 63079233a7f..07c68083cb2 100644 --- a/modules/storages/config/locales/crowdin/ms.yml +++ b/modules/storages/config/locales/crowdin/ms.yml @@ -279,6 +279,8 @@ ms: drive_contents: Drive content files_request: Fetching team folder files header: Folder projek yang dikendalikan secara automatik + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -320,8 +322,10 @@ ms: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Kata laluan apl yang dikonfigurasikan adalah tidak sah. not_configured: Sambungan tidak dapat disahkan. Sila selesaikan konfigurasi dahulu. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ne.yml b/modules/storages/config/locales/crowdin/ne.yml index 22ed8cb8d28..12e1b811827 100644 --- a/modules/storages/config/locales/crowdin/ne.yml +++ b/modules/storages/config/locales/crowdin/ne.yml @@ -279,6 +279,8 @@ ne: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ ne: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/nl.yml b/modules/storages/config/locales/crowdin/nl.yml index e12dc9efee4..53118fc8189 100644 --- a/modules/storages/config/locales/crowdin/nl.yml +++ b/modules/storages/config/locales/crowdin/nl.yml @@ -279,6 +279,8 @@ nl: drive_contents: Drive content files_request: Fetching team folder files header: Automatisch beheerde projectmappen + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ nl: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: De verbinding kon niet gevalideerd worden. Voltooi eerst de configuratie. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/no.yml b/modules/storages/config/locales/crowdin/no.yml index 55e2ea331dc..d36b1352d19 100644 --- a/modules/storages/config/locales/crowdin/no.yml +++ b/modules/storages/config/locales/crowdin/no.yml @@ -279,6 +279,8 @@ drive_contents: Drive content files_request: Fetching team folder files header: Automatisk administrerte prosjektmapper + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/pl.yml b/modules/storages/config/locales/crowdin/pl.yml index 218db959b31..85cc52c569b 100644 --- a/modules/storages/config/locales/crowdin/pl.yml +++ b/modules/storages/config/locales/crowdin/pl.yml @@ -279,6 +279,8 @@ pl: drive_contents: Zawartość dysku files_request: Pobieranie plików folderu zespołu header: Automatycznie zarządzane foldery projektu + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Zależność: foldery zespołów' team_folder_contents: Zawartość folderu zespołu team_folder_presence: Folder zespołu istnieje @@ -326,8 +328,10 @@ pl: nc_oauth_request_not_found: Nie znaleziono punktu końcowego, z którego można pobrać informacje o aktualnie połączonym użytkowniku. Aby uzyskać więcej informacji, sprawdź dzienniki serwera. nc_oauth_request_unauthorized: Bieżący użytkownik nie ma uprawnień dostępu do zdalnego magazynu plików. Aby uzyskać więcej informacji, sprawdź dzienniki serwera. nc_oauth_token_missing: Aplikacja OpenProject nie może przetestować komunikacji z usługą Nextcloud na poziomie użytkownika, ponieważ użytkownik nie powiązał jeszcze swojego konta Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Nie można znaleźć folderu zespołu. - nc_unexpected_content: W folderze zarządzanego zespołu znaleziono nieoczekiwaną zawartość. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Skonfigurowane hasło aplikacji jest nieprawidłowe. not_configured: Nie można zweryfikować połączenia. Najpierw zakończ konfigurację. od_client_cant_delete_folder: Klient ma problemy z usunięciem folderów. Sprawdź dokumentację konfiguracji swojej pamięci masowej. diff --git a/modules/storages/config/locales/crowdin/pt-BR.yml b/modules/storages/config/locales/crowdin/pt-BR.yml index 758e081c9f0..c23f5735a9b 100644 --- a/modules/storages/config/locales/crowdin/pt-BR.yml +++ b/modules/storages/config/locales/crowdin/pt-BR.yml @@ -279,6 +279,8 @@ pt-BR: drive_contents: Conteúdo da unidade files_request: Buscando arquivos da pasta da equipe header: Pastas de projeto gerenciadas automaticamente + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependência: Pastas da equipe' team_folder_contents: Conteúdo da pasta da equipe team_folder_presence: A pasta da equipe existe @@ -322,8 +324,10 @@ pt-BR: nc_oauth_request_not_found: O endpoint para recuperar o usuário conectado não foi encontrado. Verifique os logs do servidor para mais detalhes. nc_oauth_request_unauthorized: O usuário atual não está autorizado a acessar o armazenamento remoto de arquivos. Verifique os logs do servidor para mais informações. nc_oauth_token_missing: O OpenProject não pode testar a comunicação do usuário com o Nextcloud, pois a conta do Nextcloud ainda não foi vinculada. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: A pasta da equipe não pôde ser localizada. - nc_unexpected_content: Conteúdo inesperado encontrado na pasta da equipe gerenciado. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: A senha configurada do aplicativo não é válida. not_configured: A conexão não pôde ser validada. Conclua a configuração primeiro. od_client_cant_delete_folder: O cliente está com dificuldades para excluir pastas. Verifique a documentação de configuração do seu armazenamento. diff --git a/modules/storages/config/locales/crowdin/pt-PT.yml b/modules/storages/config/locales/crowdin/pt-PT.yml index eb9359c31ce..e6c2356933a 100644 --- a/modules/storages/config/locales/crowdin/pt-PT.yml +++ b/modules/storages/config/locales/crowdin/pt-PT.yml @@ -279,6 +279,8 @@ pt-PT: drive_contents: Conteúdo da unidade files_request: A obter ficheiros da pasta de equipa header: Pastas do projeto geridas automaticamente + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependência: Pastas de equipa' team_folder_contents: Conteúdo da pasta de equipa team_folder_presence: A pasta de equipa existe @@ -322,8 +324,10 @@ pt-PT: nc_oauth_request_not_found: O terminal para o utilizador com sessão iniciada não foi encontrado. Consulte os registos do servidor para mais informações. nc_oauth_request_unauthorized: O utilizador atual não está autorizado a aceder ao armazenamento de ficheiros remoto. Verifique os registos do servidor para mais informações. nc_oauth_token_missing: O OpenProject não pode testar a comunicação de nível de utilizador com o Nextcloud uma vez que o utilizador ainda não associou a sua conta Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Não foi possível encontrar a pasta de equipa. - nc_unexpected_content: Conteúdo inesperado encontrado na pasta de equipa gerida. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: A palavra-passe da aplicação configurada é inválida. not_configured: Não foi possível validar a ligação.Termine, primeiro, a configuração. od_client_cant_delete_folder: O cliente está a ter problemas a eliminar pastas. Consulte a documentação de configuração para o seu armazenamento. diff --git a/modules/storages/config/locales/crowdin/ro.yml b/modules/storages/config/locales/crowdin/ro.yml index fe53bf33018..f28909c0e97 100644 --- a/modules/storages/config/locales/crowdin/ro.yml +++ b/modules/storages/config/locales/crowdin/ro.yml @@ -279,6 +279,8 @@ ro: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -324,8 +326,10 @@ ro: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: Utilizatorul curent nu este autorizat să acceseze spațiul de stocare de la distanță. Te rog să verifici jurnalele serverului pentru informații suplimentare. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/ru.yml b/modules/storages/config/locales/crowdin/ru.yml index 3720a34f7d2..7e8a96aecee 100644 --- a/modules/storages/config/locales/crowdin/ru.yml +++ b/modules/storages/config/locales/crowdin/ru.yml @@ -279,6 +279,8 @@ ru: drive_contents: Содержимое диска files_request: Получение файлов папки команды header: Автоматически управляемые папки проекта + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Зависимость: папки команды' team_folder_contents: Содержимое папки команды team_folder_presence: Папка команды существует @@ -326,8 +328,10 @@ ru: nc_oauth_request_not_found: Конечная точка для получения данных о текущем подключенном пользователе не найдена. Пожалуйста, проверьте журналы сервера для получения дополнительной информации. nc_oauth_request_unauthorized: Текущий пользователь не авторизован для доступа к удаленному файловому хранилищу. Пожалуйста, проверьте журналы сервера для получения дополнительной информации. nc_oauth_token_missing: OpenProject не может протестировать пользовательскую связь с Nextcloud, так как пользователь еще не связал свою учетную запись Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Папка команды не найдена. - nc_unexpected_content: Непредвиденное содержимое найдено в папке команды. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Указанный пароль приложения неверен. not_configured: Соединение не удалось подтвердить. Пожалуйста, сначала завершите настройку. od_client_cant_delete_folder: У клиента возникли проблемы с удалением папок. Пожалуйста, проверьте документацию по конфигурации для вашего хранилища. diff --git a/modules/storages/config/locales/crowdin/rw.yml b/modules/storages/config/locales/crowdin/rw.yml index 1929534043c..1ec973eee78 100644 --- a/modules/storages/config/locales/crowdin/rw.yml +++ b/modules/storages/config/locales/crowdin/rw.yml @@ -279,6 +279,8 @@ rw: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ rw: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/si.yml b/modules/storages/config/locales/crowdin/si.yml index f507d006da4..daca008ee39 100644 --- a/modules/storages/config/locales/crowdin/si.yml +++ b/modules/storages/config/locales/crowdin/si.yml @@ -279,6 +279,8 @@ si: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ si: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/sk.yml b/modules/storages/config/locales/crowdin/sk.yml index 51ec74a1a9e..f3d4b5a32ab 100644 --- a/modules/storages/config/locales/crowdin/sk.yml +++ b/modules/storages/config/locales/crowdin/sk.yml @@ -279,6 +279,8 @@ sk: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -326,8 +328,10 @@ sk: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/sl.yml b/modules/storages/config/locales/crowdin/sl.yml index 8dddda1fdce..1b460882ece 100644 --- a/modules/storages/config/locales/crowdin/sl.yml +++ b/modules/storages/config/locales/crowdin/sl.yml @@ -279,6 +279,8 @@ sl: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -326,8 +328,10 @@ sl: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/sr.yml b/modules/storages/config/locales/crowdin/sr.yml index 88fa6a222ad..304c7d4510e 100644 --- a/modules/storages/config/locales/crowdin/sr.yml +++ b/modules/storages/config/locales/crowdin/sr.yml @@ -279,6 +279,8 @@ sr: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -324,8 +326,10 @@ sr: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/sv.yml b/modules/storages/config/locales/crowdin/sv.yml index 9e7f23b7155..fa3373bca6d 100644 --- a/modules/storages/config/locales/crowdin/sv.yml +++ b/modules/storages/config/locales/crowdin/sv.yml @@ -279,6 +279,8 @@ sv: drive_contents: Diskinnehåll files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ sv: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/th.yml b/modules/storages/config/locales/crowdin/th.yml index 14ebcf67d55..87735d3236c 100644 --- a/modules/storages/config/locales/crowdin/th.yml +++ b/modules/storages/config/locales/crowdin/th.yml @@ -279,6 +279,8 @@ th: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -320,8 +322,10 @@ th: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/tr.yml b/modules/storages/config/locales/crowdin/tr.yml index 6d69727bff5..723f543f3c3 100644 --- a/modules/storages/config/locales/crowdin/tr.yml +++ b/modules/storages/config/locales/crowdin/tr.yml @@ -279,6 +279,8 @@ tr: drive_contents: Sücürü içeriği files_request: Takım klasöründeki dosyaları getir header: Otomatik olarak yönetilen proje klasörleri + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Bağımlılık: Ekip Klasörleri' team_folder_contents: Ekip klasörü içeriği team_folder_presence: Ekip klasörü mevcut @@ -322,8 +324,10 @@ tr: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Ekip klasörü bulunamadı. - nc_unexpected_content: Yönetilen ekip klasöründe beklenmeyen içerik bulundu. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Yapılandırılan uygulama parolası geçersiz. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/uk.yml b/modules/storages/config/locales/crowdin/uk.yml index cb66081a270..8a7822561e2 100644 --- a/modules/storages/config/locales/crowdin/uk.yml +++ b/modules/storages/config/locales/crowdin/uk.yml @@ -279,6 +279,8 @@ uk: drive_contents: Вміст диска files_request: Отримання файлів із папок команди header: Папки проєкту з автоматичним керуванням + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Залежність: папки команди' team_folder_contents: Вміст папки команди team_folder_presence: Папка команди існує @@ -326,8 +328,10 @@ uk: nc_oauth_request_not_found: Кінцеву точку для отримання підключеного зараз користувача не знайдено. Щоб дізнатися більше, перевірте журнали сервера. nc_oauth_request_unauthorized: Поточний користувач не має дозволу на доступ до віддаленого файлового сховища. Щоб дізнатися більше, перевірте журнали сервера. nc_oauth_token_missing: OpenProject не може перевірити з’єднання на рівні користувача з Nextcloud, оскільки користувач досі не зв’язав свій обліковий запис Nextcloud. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Папку команди не знайдено. - nc_unexpected_content: У папці керованої команди знайдено неочікуваний вміст. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Налаштований пароль застосунку недійсний. not_configured: Не вдалося перевірити підключення. Спочатку налаштуйте конфігурацію. od_client_cant_delete_folder: Клієнту не вдалося видалити папки. Ознайомтеся з документацією щодо конфігурації для свого сховища. diff --git a/modules/storages/config/locales/crowdin/uz.yml b/modules/storages/config/locales/crowdin/uz.yml index 0a70e8d2a64..4eae84ecff5 100644 --- a/modules/storages/config/locales/crowdin/uz.yml +++ b/modules/storages/config/locales/crowdin/uz.yml @@ -279,6 +279,8 @@ uz: drive_contents: Drive content files_request: Fetching team folder files header: Automatically managed project folders + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Dependency: Team Folders' team_folder_contents: Team folder content team_folder_presence: Team folder exists @@ -322,8 +324,10 @@ uz: nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information. nc_oauth_request_unauthorized: The current user isn't authorized to access the remote file storage. Please check the server logs for further information. nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: The team folder could not be found. - nc_unexpected_content: Unexpected content found in the managed team folder. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: The configured app password is invalid. not_configured: The connection could not be validated. Please finish configuration first. od_client_cant_delete_folder: The client is having trouble deleting folders. Please check the setup documentation for your storage. diff --git a/modules/storages/config/locales/crowdin/vi.yml b/modules/storages/config/locales/crowdin/vi.yml index df28cd56424..597521e54c3 100644 --- a/modules/storages/config/locales/crowdin/vi.yml +++ b/modules/storages/config/locales/crowdin/vi.yml @@ -279,6 +279,8 @@ vi: drive_contents: Nội dung ổ đĩa files_request: Tải xuống các tệp trong thư mục của nhóm header: Thư mục dự án được quản lý tự động + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: 'Phụ thuộc: Thư mục nhóm' team_folder_contents: Nội dung thư mục của nhóm team_folder_presence: Thư mục nhóm đã tồn tại @@ -320,8 +322,10 @@ vi: nc_oauth_request_not_found: Không tìm thấy điểm cuối để tìm nạp người dùng hiện được kết nối. Vui lòng kiểm tra nhật ký máy chủ để biết thêm thông tin. nc_oauth_request_unauthorized: Người dùng hiện tại không được phép truy cập vào bộ lưu trữ tệp từ xa. Vui lòng kiểm tra nhật ký máy chủ để biết thêm thông tin. nc_oauth_token_missing: OpenProject không thể kiểm tra giao tiếp ở cấp độ người dùng với Nextcloud vì người dùng chưa liên kết tài khoản Nextcloud của họ. + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: Thư mục nhóm không thể được tìm thấy. - nc_unexpected_content: Nội dung không mong đợi được tìm thấy trong thư mục của nhóm được quản lý. + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: Mật khẩu ứng dụng đã định cấu hình không hợp lệ. not_configured: Kết nối không thể được xác nhận. Vui lòng hoàn tất cấu hình trước. od_client_cant_delete_folder: Máy khách đang gặp sự cố khi xóa các thư mục. Vui lòng kiểm tra tài liệu thiết lập cho bộ nhớ của bạn. diff --git a/modules/storages/config/locales/crowdin/zh-CN.yml b/modules/storages/config/locales/crowdin/zh-CN.yml index b24edb87242..3f3410abbab 100644 --- a/modules/storages/config/locales/crowdin/zh-CN.yml +++ b/modules/storages/config/locales/crowdin/zh-CN.yml @@ -279,6 +279,8 @@ zh-CN: drive_contents: 驱动器内容 files_request: 正在获取团队文件夹文件 header: 自动托管的项目文件夹 + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: '依赖关系:团队文件夹' team_folder_contents: 团队文件夹内容 team_folder_presence: 团队文件夹已存在 @@ -320,8 +322,10 @@ zh-CN: nc_oauth_request_not_found: 未找到获取当前连接用户的端点。请检查服务器日志以获取更多信息。 nc_oauth_request_unauthorized: 当前用户无权访问远程文件存储。请检查服务器日志以获取更多信息。 nc_oauth_token_missing: OpenProject 无法测试用户与 Nextcloud 之间的通信,因为用户尚未链接他们的 Nextcloud 帐户。 + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: 找不到该团队文件夹。 - nc_unexpected_content: 在受管理的团队文件夹中找到非预期内容。 + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: 已配置的应用密码无效。 not_configured: 无法验证连接。请先完成配置。 od_client_cant_delete_folder: 客户端在删除文件夹时遇到问题。请检查您的存储的设置文档。 diff --git a/modules/storages/config/locales/crowdin/zh-TW.yml b/modules/storages/config/locales/crowdin/zh-TW.yml index 1f63448d06d..78788b4ad73 100644 --- a/modules/storages/config/locales/crowdin/zh-TW.yml +++ b/modules/storages/config/locales/crowdin/zh-TW.yml @@ -279,6 +279,8 @@ zh-TW: drive_contents: 磁碟內容 files_request: 擷取團隊資料夾檔案 header: 自動管理的專案資料夾 + project_folders_exist: Project folders exist + project_folders_linked: Project folders linked team_folder_app: '依賴性:團隊文件夾' team_folder_contents: 團隊資料夾內容 team_folder_presence: 團隊資料夾存在 @@ -320,8 +322,10 @@ zh-TW: nc_oauth_request_not_found: 無法找到取得目前連線使用者的端點。請檢查伺服器日誌以取得更多資訊。 nc_oauth_request_unauthorized: 當前使用者未獲授權存取遠端檔案儲存空間。請檢查伺服器日誌以取得進一步資訊。 nc_oauth_token_missing: OpenProject 無法測試與 Nextcloud 的用戶級連線,因為沒有當前用戶的令牌(Token)。 + nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found. nc_team_folder_not_found: 找不到團隊資料夾。 - nc_unexpected_content: 在管理的團隊資料夾中發現意外內容。 + nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}' + nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization. nc_userless_access_denied: 設定的應用程式密碼無效。 not_configured: 連線無法驗證。請先完成設定。 od_client_cant_delete_folder: 用戶刪除資料夾失敗,請檢查儲存空間的文件設定。 From c980dee8c8556c703798ed0d0743f70c1ec12828 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Wed, 18 Mar 2026 04:04:22 +0000 Subject: [PATCH 220/435] update locales from crowdin [ci skip] --- config/locales/crowdin/af.yml | 2 +- config/locales/crowdin/ar.yml | 2 +- config/locales/crowdin/az.yml | 2 +- config/locales/crowdin/be.yml | 2 +- config/locales/crowdin/bg.yml | 2 +- config/locales/crowdin/ca.yml | 2 +- config/locales/crowdin/ckb-IR.yml | 2 +- config/locales/crowdin/cs.yml | 4 ++-- config/locales/crowdin/da.yml | 2 +- config/locales/crowdin/de.yml | 2 +- config/locales/crowdin/el.yml | 2 +- config/locales/crowdin/eo.yml | 2 +- config/locales/crowdin/es.yml | 2 +- config/locales/crowdin/et.yml | 2 +- config/locales/crowdin/eu.yml | 2 +- config/locales/crowdin/fa.yml | 2 +- config/locales/crowdin/fi.yml | 2 +- config/locales/crowdin/fil.yml | 2 +- config/locales/crowdin/fr.yml | 2 +- config/locales/crowdin/he.yml | 2 +- config/locales/crowdin/hi.yml | 2 +- config/locales/crowdin/hr.yml | 2 +- config/locales/crowdin/hu.yml | 2 +- config/locales/crowdin/id.yml | 2 +- config/locales/crowdin/it.yml | 2 +- config/locales/crowdin/ja.yml | 2 +- config/locales/crowdin/js-fr.yml | 2 +- config/locales/crowdin/ka.yml | 2 +- config/locales/crowdin/kk.yml | 2 +- config/locales/crowdin/ko.yml | 2 +- config/locales/crowdin/lt.yml | 2 +- config/locales/crowdin/lv.yml | 2 +- config/locales/crowdin/mn.yml | 2 +- config/locales/crowdin/ms.yml | 2 +- config/locales/crowdin/ne.yml | 2 +- config/locales/crowdin/nl.yml | 2 +- config/locales/crowdin/no.yml | 2 +- config/locales/crowdin/pl.yml | 2 +- config/locales/crowdin/pt-BR.yml | 2 +- config/locales/crowdin/pt-PT.yml | 2 +- config/locales/crowdin/ro.yml | 2 +- config/locales/crowdin/ru.yml | 2 +- config/locales/crowdin/rw.yml | 2 +- config/locales/crowdin/si.yml | 2 +- config/locales/crowdin/sk.yml | 2 +- config/locales/crowdin/sl.yml | 2 +- config/locales/crowdin/sr.yml | 2 +- config/locales/crowdin/sv.yml | 2 +- config/locales/crowdin/th.yml | 2 +- config/locales/crowdin/tr.yml | 2 +- config/locales/crowdin/uk.yml | 2 +- config/locales/crowdin/uz.yml | 2 +- config/locales/crowdin/vi.yml | 2 +- config/locales/crowdin/zh-CN.yml | 2 +- config/locales/crowdin/zh-TW.yml | 2 +- modules/budgets/config/locales/crowdin/cs.yml | 4 ++-- modules/costs/config/locales/crowdin/cs.yml | 16 ++++++++-------- modules/documents/config/locales/crowdin/cs.yml | 2 +- modules/grids/config/locales/crowdin/cs.yml | 6 +++--- modules/grids/config/locales/crowdin/js-cs.yml | 2 +- modules/meeting/config/locales/crowdin/cs.yml | 2 +- modules/meeting/config/locales/crowdin/fr.yml | 2 +- 62 files changed, 73 insertions(+), 73 deletions(-) diff --git a/config/locales/crowdin/af.yml b/config/locales/crowdin/af.yml index 7560dcb3221..163922a2908 100644 --- a/config/locales/crowdin/af.yml +++ b/config/locales/crowdin/af.yml @@ -4221,7 +4221,7 @@ af: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/ar.yml b/config/locales/crowdin/ar.yml index dba656e844b..9f2a881444f 100644 --- a/config/locales/crowdin/ar.yml +++ b/config/locales/crowdin/ar.yml @@ -4449,7 +4449,7 @@ ar: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "لم يتم حذف المشروع." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "اتصال ناجح." notice_successful_create: "إنشاء ناجح." notice_successful_delete: "حذف ناجح." diff --git a/config/locales/crowdin/az.yml b/config/locales/crowdin/az.yml index 4fb6680e711..c0e2d31c54a 100644 --- a/config/locales/crowdin/az.yml +++ b/config/locales/crowdin/az.yml @@ -4221,7 +4221,7 @@ az: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/be.yml b/config/locales/crowdin/be.yml index e8f3812979b..342bdda8035 100644 --- a/config/locales/crowdin/be.yml +++ b/config/locales/crowdin/be.yml @@ -4335,7 +4335,7 @@ be: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/bg.yml b/config/locales/crowdin/bg.yml index e797445f3f4..46f71a5a182 100644 --- a/config/locales/crowdin/bg.yml +++ b/config/locales/crowdin/bg.yml @@ -4221,7 +4221,7 @@ bg: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Проектът не е изтрит." notice_project_not_found: "Проектът не е намерен." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Успешна връзка." notice_successful_create: "Успешно създаване." notice_successful_delete: "Успешно изтриване." diff --git a/config/locales/crowdin/ca.yml b/config/locales/crowdin/ca.yml index 4675ae2a7ea..4e6707ec15a 100644 --- a/config/locales/crowdin/ca.yml +++ b/config/locales/crowdin/ca.yml @@ -4215,7 +4215,7 @@ ca: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "El projecte no s'ha suprimit." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "S'ha connectat correctament." notice_successful_create: "Creat correctament." notice_successful_delete: "Esborrat correctament." diff --git a/config/locales/crowdin/ckb-IR.yml b/config/locales/crowdin/ckb-IR.yml index df371928f88..a893fb52d61 100644 --- a/config/locales/crowdin/ckb-IR.yml +++ b/config/locales/crowdin/ckb-IR.yml @@ -4221,7 +4221,7 @@ ckb-IR: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index dd4cf676f5e..6a8c1c7c0ea 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -3964,7 +3964,7 @@ cs: label_subproject: "Podprojekt" label_subproject_new: "Nový podprojekt" label_subproject_plural: "Podprojekty" - label_subitems: "Subitems" + label_subitems: "Dílčí položky" label_subtask_plural: "Podúkoly" label_summary: "Souhrn" label_system: "Systém" @@ -4334,7 +4334,7 @@ cs: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projekt nebyl odstraněn." notice_project_not_found: "Projekt nebyl nalezen." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Úspěšné připojení." notice_successful_create: "Úspěšné vytvoření." notice_successful_delete: "Úspěšné odstranění." diff --git a/config/locales/crowdin/da.yml b/config/locales/crowdin/da.yml index 945398f223a..95b0ae94ff5 100644 --- a/config/locales/crowdin/da.yml +++ b/config/locales/crowdin/da.yml @@ -4219,7 +4219,7 @@ da: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projektet blev ikke slettet." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Forbindelse gennemført." notice_successful_create: "Oprettelse gennemført." notice_successful_delete: "Sletning gennemført." diff --git a/config/locales/crowdin/de.yml b/config/locales/crowdin/de.yml index 7ad65decd38..5535cfd11d0 100644 --- a/config/locales/crowdin/de.yml +++ b/config/locales/crowdin/de.yml @@ -4213,7 +4213,7 @@ de: notice_parent_item_not_found: "Übergeordnetes Element nicht gefunden." notice_project_not_deleted: "Das Projekt wurde nicht gelöscht." notice_project_not_found: "Projekt nicht gefunden." - notice_smtp_address_unsafe: "Die SMTP-Adresse %{address} ist nicht sicher. Bitte fügen Sie sie zu OPENPROJECT_SSRF_PROTECTION_ALLOWLIST hinzu." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Verbindung erfolgreich." notice_successful_create: "Erfolgreich angelegt." notice_successful_delete: "Erfolgreich gelöscht." diff --git a/config/locales/crowdin/el.yml b/config/locales/crowdin/el.yml index 03e7bc2aa24..185bc11e39a 100644 --- a/config/locales/crowdin/el.yml +++ b/config/locales/crowdin/el.yml @@ -4216,7 +4216,7 @@ el: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Το έργο δεν διαγράφηκε." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Επιτυχής σύνδεση." notice_successful_create: "Επιτυχής δημιουργία." notice_successful_delete: "Επιτυχής διαγραφή." diff --git a/config/locales/crowdin/eo.yml b/config/locales/crowdin/eo.yml index 6779fbdf68b..91f0a0e977d 100644 --- a/config/locales/crowdin/eo.yml +++ b/config/locales/crowdin/eo.yml @@ -4221,7 +4221,7 @@ eo: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/es.yml b/config/locales/crowdin/es.yml index 7cf426f2210..c3645102ab0 100644 --- a/config/locales/crowdin/es.yml +++ b/config/locales/crowdin/es.yml @@ -4218,7 +4218,7 @@ es: notice_parent_item_not_found: "Elemento padre no encontrado." notice_project_not_deleted: "El proyecto no fue eliminado." notice_project_not_found: "Proyecto no encontrado." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Conexión exitosa." notice_successful_create: "Creación exitosa." notice_successful_delete: "Eliminado con éxito." diff --git a/config/locales/crowdin/et.yml b/config/locales/crowdin/et.yml index f13d5fd5a4f..5fd8e489728 100644 --- a/config/locales/crowdin/et.yml +++ b/config/locales/crowdin/et.yml @@ -4221,7 +4221,7 @@ et: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projekti ei ole kustutatud." notice_project_not_found: "Projekti ei leitud." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Ühenduse loomine õnnestus." notice_successful_create: "Loomine õnnestus." notice_successful_delete: "Kustutamine õnnestus." diff --git a/config/locales/crowdin/eu.yml b/config/locales/crowdin/eu.yml index 76789185613..b188ebcab9d 100644 --- a/config/locales/crowdin/eu.yml +++ b/config/locales/crowdin/eu.yml @@ -4221,7 +4221,7 @@ eu: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/fa.yml b/config/locales/crowdin/fa.yml index 12cc5d96079..996cc5a1e9b 100644 --- a/config/locales/crowdin/fa.yml +++ b/config/locales/crowdin/fa.yml @@ -4221,7 +4221,7 @@ fa: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/fi.yml b/config/locales/crowdin/fi.yml index 6b5fb883c21..374f4993bef 100644 --- a/config/locales/crowdin/fi.yml +++ b/config/locales/crowdin/fi.yml @@ -4221,7 +4221,7 @@ fi: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Yhteyden muodostus onnistui." notice_successful_create: "Luonti onnistui." notice_successful_delete: "Poisto onnistui." diff --git a/config/locales/crowdin/fil.yml b/config/locales/crowdin/fil.yml index 5431f3454f8..14b05d728d6 100644 --- a/config/locales/crowdin/fil.yml +++ b/config/locales/crowdin/fil.yml @@ -4221,7 +4221,7 @@ fil: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Ang proyekto ay hindi nabura." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Matagumpay na ikonekta." notice_successful_create: "Matagumpay pagkalikha." notice_successful_delete: "Matagumpay ang pagtanggal." diff --git a/config/locales/crowdin/fr.yml b/config/locales/crowdin/fr.yml index 4dd8c3a4cd5..5a528c7bc50 100644 --- a/config/locales/crowdin/fr.yml +++ b/config/locales/crowdin/fr.yml @@ -4219,7 +4219,7 @@ fr: notice_parent_item_not_found: "L'élément parent est introuvable." notice_project_not_deleted: "Le projet n'a pas été supprimé." notice_project_not_found: "Projet introuvable." - notice_smtp_address_unsafe: "L'adresse SMTP %{address} n'est pas sûre. Veuillez l'ajouter à OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Connexion réussie." notice_successful_create: "Création réussie." notice_successful_delete: "Suppression réussie." diff --git a/config/locales/crowdin/he.yml b/config/locales/crowdin/he.yml index 09834c23b46..55c6765e015 100644 --- a/config/locales/crowdin/he.yml +++ b/config/locales/crowdin/he.yml @@ -4335,7 +4335,7 @@ he: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/hi.yml b/config/locales/crowdin/hi.yml index 9edebc1a0b8..1a9d7c18ae9 100644 --- a/config/locales/crowdin/hi.yml +++ b/config/locales/crowdin/hi.yml @@ -4219,7 +4219,7 @@ hi: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/hr.yml b/config/locales/crowdin/hr.yml index b31fd47a678..10dff54d8b7 100644 --- a/config/locales/crowdin/hr.yml +++ b/config/locales/crowdin/hr.yml @@ -4278,7 +4278,7 @@ hr: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projekt nije izbrisan." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Uspješna spojen." notice_successful_create: "Uspješno kreirano." notice_successful_delete: "Brisanje uspješno." diff --git a/config/locales/crowdin/hu.yml b/config/locales/crowdin/hu.yml index ffaba5f5d36..1274f34175b 100644 --- a/config/locales/crowdin/hu.yml +++ b/config/locales/crowdin/hu.yml @@ -4219,7 +4219,7 @@ hu: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "A projekt nem lett törölve." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Sikeresen létrejött a kapcsolat." notice_successful_create: "Sikeres létrehozás." notice_successful_delete: "Sikeres törlés." diff --git a/config/locales/crowdin/id.yml b/config/locales/crowdin/id.yml index 97be06bd288..d07f52f2e1c 100644 --- a/config/locales/crowdin/id.yml +++ b/config/locales/crowdin/id.yml @@ -4160,7 +4160,7 @@ id: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Project tidak dihapus." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Sambungan berhasil." notice_successful_create: "Berhasil dibuat." notice_successful_delete: "Berhasil dihapus." diff --git a/config/locales/crowdin/it.yml b/config/locales/crowdin/it.yml index 71fae7fe806..46d8c11c16c 100644 --- a/config/locales/crowdin/it.yml +++ b/config/locales/crowdin/it.yml @@ -4218,7 +4218,7 @@ it: notice_parent_item_not_found: "L'elemento genitore non è stato trovato." notice_project_not_deleted: "Il progetto non è stato eliminato." notice_project_not_found: "Progetto non trovato." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Connesso con successo." notice_successful_create: "Creato con successo." notice_successful_delete: "Cancellato con successo." diff --git a/config/locales/crowdin/ja.yml b/config/locales/crowdin/ja.yml index a3bf9537a79..57d0fed0b18 100644 --- a/config/locales/crowdin/ja.yml +++ b/config/locales/crowdin/ja.yml @@ -4162,7 +4162,7 @@ ja: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "プロジェクトを削除していません。" notice_project_not_found: "プロジェクトが見つかりません。" - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "正常に接続しました。" notice_successful_create: "正常に作成しました。" notice_successful_delete: "正常に削除しました。" diff --git a/config/locales/crowdin/js-fr.yml b/config/locales/crowdin/js-fr.yml index d96d569ffdc..0cd20ffc988 100644 --- a/config/locales/crowdin/js-fr.yml +++ b/config/locales/crowdin/js-fr.yml @@ -993,7 +993,7 @@ fr: embedded_tab_disabled: "Cet onglet de configuration n'est pas disponible pour la vue intégrée que vous êtes en train de modifier." default: "défaut" display_settings: "Paramètres d'affichage" - default_mode: "Liste ombrée" + default_mode: "Liste" hierarchy_mode: "Hiérarchie" hierarchy_hint: "Tous les résultats du tableau filtrés seront augmentés de leurs ancêtres . Les hiérarchies peuvent être dépliées et repliées." display_sums_hint: "Afficher les sommes de tous les attributs sommables dans une ligne sous les résultats du tableau." diff --git a/config/locales/crowdin/ka.yml b/config/locales/crowdin/ka.yml index d3437bd3170..86ed4916a23 100644 --- a/config/locales/crowdin/ka.yml +++ b/config/locales/crowdin/ka.yml @@ -4221,7 +4221,7 @@ ka: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/kk.yml b/config/locales/crowdin/kk.yml index 60142b65d61..8402614de98 100644 --- a/config/locales/crowdin/kk.yml +++ b/config/locales/crowdin/kk.yml @@ -4221,7 +4221,7 @@ kk: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/ko.yml b/config/locales/crowdin/ko.yml index 7c128c388a5..68058e90e59 100644 --- a/config/locales/crowdin/ko.yml +++ b/config/locales/crowdin/ko.yml @@ -4164,7 +4164,7 @@ ko: notice_parent_item_not_found: "부모 항목을 찾을 수 없습니다." notice_project_not_deleted: "프로젝트가 삭제되지 않았습니다." notice_project_not_found: "프로젝트를 찾을 수 없습니다." - notice_smtp_address_unsafe: "SMTP 주소 %{address}은(는) 안전하지 않습니다. OPENPROJECT_SSRF_PROTECTION_ALLOWLIST에 추가하세요." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "연결에 성공했습니다." notice_successful_create: "생성에 성공했습니다." notice_successful_delete: "삭제에 성공했습니다." diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index 2695f2ed81b..f921d493031 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -4332,7 +4332,7 @@ lt: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projektas nebuvo panaikintas." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Sėkmingas susijungimas." notice_successful_create: "Sėkmingas sukūrimas." notice_successful_delete: "Sėkmingas panaikinimas." diff --git a/config/locales/crowdin/lv.yml b/config/locales/crowdin/lv.yml index bf427456467..7b5362a2127 100644 --- a/config/locales/crowdin/lv.yml +++ b/config/locales/crowdin/lv.yml @@ -4278,7 +4278,7 @@ lv: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/mn.yml b/config/locales/crowdin/mn.yml index 4d9b083a9c1..7f182c3ea40 100644 --- a/config/locales/crowdin/mn.yml +++ b/config/locales/crowdin/mn.yml @@ -4221,7 +4221,7 @@ mn: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/ms.yml b/config/locales/crowdin/ms.yml index a9aebc96b7d..73af5cf02ca 100644 --- a/config/locales/crowdin/ms.yml +++ b/config/locales/crowdin/ms.yml @@ -4162,7 +4162,7 @@ ms: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projek tersebut tidak dipadam." notice_project_not_found: "Projek tidak ditemui." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Sambungan berjaya." notice_successful_create: "Penciptaan yang berjaya." notice_successful_delete: "Pemadaman yang berjaya." diff --git a/config/locales/crowdin/ne.yml b/config/locales/crowdin/ne.yml index f6a50986e85..641291065b7 100644 --- a/config/locales/crowdin/ne.yml +++ b/config/locales/crowdin/ne.yml @@ -4221,7 +4221,7 @@ ne: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/nl.yml b/config/locales/crowdin/nl.yml index 27a36ab4708..7f8c913e427 100644 --- a/config/locales/crowdin/nl.yml +++ b/config/locales/crowdin/nl.yml @@ -4216,7 +4216,7 @@ nl: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Het project is niet verwijderd." notice_project_not_found: "Project niet gevonden." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Geslaagde verbinding." notice_successful_create: "Aanmaak geslaagd." notice_successful_delete: "Verwijdering geslaagd." diff --git a/config/locales/crowdin/no.yml b/config/locales/crowdin/no.yml index 11cab7c8348..28cfb7cae48 100644 --- a/config/locales/crowdin/no.yml +++ b/config/locales/crowdin/no.yml @@ -4220,7 +4220,7 @@ notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Prosjektet ble ikke slettet." notice_project_not_found: "Prosjektet ble ikke funnet." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Vellykket tilkobling." notice_successful_create: "Opprettelsen var vellykket." notice_successful_delete: "Slettingen var vellykket." diff --git a/config/locales/crowdin/pl.yml b/config/locales/crowdin/pl.yml index b9e6678eb5f..350f6022d5b 100644 --- a/config/locales/crowdin/pl.yml +++ b/config/locales/crowdin/pl.yml @@ -4330,7 +4330,7 @@ pl: notice_parent_item_not_found: "Nie znaleziono elementu nadrzędnego." notice_project_not_deleted: "Projekt nie został usunięty." notice_project_not_found: "Nie znaleziono projektu." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Połączenie zakończone sukcesem." notice_successful_create: "Tworzenie zakończone sukcesem." notice_successful_delete: "Usuwanie zakończone sukcesem." diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index 4d569b0080d..2aeaff87a1b 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -4217,7 +4217,7 @@ pt-BR: notice_parent_item_not_found: "Item pai não encontrado." notice_project_not_deleted: "O projeto não foi excluído." notice_project_not_found: "Projeto não encontrado." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Conectado com sucesso." notice_successful_create: "Criado com sucesso." notice_successful_delete: "Exclusão bem sucedida." diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index 3e0cfb1f9c3..fbd8a8483b7 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -4217,7 +4217,7 @@ pt-PT: notice_parent_item_not_found: "O elemento pai não foi encontrado." notice_project_not_deleted: "O projeto não foi eliminado." notice_project_not_found: "Projeto não encontrado." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Ligação bem sucedida." notice_successful_create: "Criado com sucesso." notice_successful_delete: "Eliminado com sucesso." diff --git a/config/locales/crowdin/ro.yml b/config/locales/crowdin/ro.yml index 620c31bb3fb..44400e81894 100644 --- a/config/locales/crowdin/ro.yml +++ b/config/locales/crowdin/ro.yml @@ -4277,7 +4277,7 @@ ro: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Proiectul nu a fost şters." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Conectare reușită." notice_successful_create: "Creare reuşită." notice_successful_delete: "Ştergere reuşită." diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index e71997ea2f5..be1e1d1773a 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -4332,7 +4332,7 @@ ru: notice_parent_item_not_found: "Родительский элемент не найден." notice_project_not_deleted: "Проект удалён не был." notice_project_not_found: "Проект не найден." - notice_smtp_address_unsafe: "SMTP-адрес %{address} небезопасен. Пожалуйста, добавьте его в список OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Подключение выполнено." notice_successful_create: "Создание выполнено." notice_successful_delete: "Удаление выполнено." diff --git a/config/locales/crowdin/rw.yml b/config/locales/crowdin/rw.yml index 23514321fd0..f677e9eedcc 100644 --- a/config/locales/crowdin/rw.yml +++ b/config/locales/crowdin/rw.yml @@ -4221,7 +4221,7 @@ rw: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/si.yml b/config/locales/crowdin/si.yml index 014261a0f42..d4d71e0ba5d 100644 --- a/config/locales/crowdin/si.yml +++ b/config/locales/crowdin/si.yml @@ -4221,7 +4221,7 @@ si: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "ව්යාපෘතිය මකා දැමුවේ නැත." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "සාර්ථක සම්බන්ධතාවයක්." notice_successful_create: "සාර්ථක නිර්මාණය." notice_successful_delete: "සාර්ථක මකාදැමීම." diff --git a/config/locales/crowdin/sk.yml b/config/locales/crowdin/sk.yml index 212388ddcce..935f04db755 100644 --- a/config/locales/crowdin/sk.yml +++ b/config/locales/crowdin/sk.yml @@ -4334,7 +4334,7 @@ sk: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projekt nebol odstránený." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Úspešne pripojené." notice_successful_create: "Úspešne vytvorené." notice_successful_delete: "Úspešne zmazané." diff --git a/config/locales/crowdin/sl.yml b/config/locales/crowdin/sl.yml index 2c8f54f95a9..3b6e3320b23 100644 --- a/config/locales/crowdin/sl.yml +++ b/config/locales/crowdin/sl.yml @@ -4334,7 +4334,7 @@ sl: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projekt ni bil izbrisan." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Povezava uspela." notice_successful_create: "Ustvarjanje uspelo." notice_successful_delete: "Uspešen izbris." diff --git a/config/locales/crowdin/sr.yml b/config/locales/crowdin/sr.yml index 9043c229fb3..cbbf37ac558 100644 --- a/config/locales/crowdin/sr.yml +++ b/config/locales/crowdin/sr.yml @@ -4278,7 +4278,7 @@ sr: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/sv.yml b/config/locales/crowdin/sv.yml index 85989c190f3..f3c7673671d 100644 --- a/config/locales/crowdin/sv.yml +++ b/config/locales/crowdin/sv.yml @@ -4221,7 +4221,7 @@ sv: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "Projektet raderades ej." notice_project_not_found: "Projektet hittas inte." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Lyckad anslutning." notice_successful_create: "Skapades utan problem." notice_successful_delete: "Raderades utan problem." diff --git a/config/locales/crowdin/th.yml b/config/locales/crowdin/th.yml index 8ab60a56856..43131ee6cef 100644 --- a/config/locales/crowdin/th.yml +++ b/config/locales/crowdin/th.yml @@ -4164,7 +4164,7 @@ th: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "ไม่ได้ลบโครงการ" notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "การเชื่อมต่อสำเร็จ" notice_successful_create: "สร้างเรียบร้อยแล้ว" notice_successful_delete: "ลบเรียบร้อยแล้ว" diff --git a/config/locales/crowdin/tr.yml b/config/locales/crowdin/tr.yml index 5e6f44e32b7..8dacce9c779 100644 --- a/config/locales/crowdin/tr.yml +++ b/config/locales/crowdin/tr.yml @@ -4220,7 +4220,7 @@ tr: notice_parent_item_not_found: "Üst öğe bulunamadı." notice_project_not_deleted: "Proje silinemedi." notice_project_not_found: "Proje bulunamadı." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Bağlantı başarılı." notice_successful_create: "Oluşturma başarılı." notice_successful_delete: "Silme başarılı." diff --git a/config/locales/crowdin/uk.yml b/config/locales/crowdin/uk.yml index dcf7caa2c5e..d22654dfd61 100644 --- a/config/locales/crowdin/uk.yml +++ b/config/locales/crowdin/uk.yml @@ -4328,7 +4328,7 @@ uk: notice_parent_item_not_found: "Батьківський об’єкт не знайдено." notice_project_not_deleted: "Проект не був видалений." notice_project_not_found: "Проєкт не знайдено." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Підключення успішно встановлене." notice_successful_create: "Створення успішно завершене." notice_successful_delete: "Видалення успішно завершене." diff --git a/config/locales/crowdin/uz.yml b/config/locales/crowdin/uz.yml index f36a383028a..6ba7e0b3772 100644 --- a/config/locales/crowdin/uz.yml +++ b/config/locales/crowdin/uz.yml @@ -4221,7 +4221,7 @@ uz: notice_parent_item_not_found: "Parent item not found." notice_project_not_deleted: "The project wasn't deleted." notice_project_not_found: "Project not found." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." diff --git a/config/locales/crowdin/vi.yml b/config/locales/crowdin/vi.yml index 80b5eee017d..4fbc0443545 100644 --- a/config/locales/crowdin/vi.yml +++ b/config/locales/crowdin/vi.yml @@ -4162,7 +4162,7 @@ vi: notice_parent_item_not_found: "Không tìm thấy mục gốc." notice_project_not_deleted: "Dự án không bị xóa." notice_project_not_found: "Dự án không được tìm thấy." - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "Kết nối thành công." notice_successful_create: "Sáng tạo thành công." notice_successful_delete: "Xóa thành công." diff --git a/config/locales/crowdin/zh-CN.yml b/config/locales/crowdin/zh-CN.yml index a36bff007f5..f9108877e93 100644 --- a/config/locales/crowdin/zh-CN.yml +++ b/config/locales/crowdin/zh-CN.yml @@ -4158,7 +4158,7 @@ zh-CN: notice_parent_item_not_found: "未找到父项" notice_project_not_deleted: "项目没有被删除" notice_project_not_found: "未找到项目。" - notice_smtp_address_unsafe: "SMTP 地址 %{address} 不安全。请将其添加到 OPENPROJECT_SSRF_PROTECTION_ALLOWLIST。" + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "成功连接。" notice_successful_create: "成功创建。" notice_successful_delete: "成功删除。" diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index 3d4cec6af91..f054aae52dd 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -4159,7 +4159,7 @@ zh-TW: notice_parent_item_not_found: "未找到父項目。" notice_project_not_deleted: "專案沒有被刪除" notice_project_not_found: "未找到專案。" - notice_smtp_address_unsafe: "SMTP address %{address} is not safe. Please add it to OPENPROJECT_SSRF_PROTECTION_ALLOWLIST." + notice_smtp_address_unsafe_env_hint: "SMTP address %{address} is not safe. Please add it to the whitelist using the %{env_name} environment variable." notice_successful_connection: "連接成功" notice_successful_create: "建立成功" notice_successful_delete: "刪除成功" diff --git a/modules/budgets/config/locales/crowdin/cs.yml b/modules/budgets/config/locales/crowdin/cs.yml index 04414639a22..eb13e395f05 100644 --- a/modules/budgets/config/locales/crowdin/cs.yml +++ b/modules/budgets/config/locales/crowdin/cs.yml @@ -71,9 +71,9 @@ cs: total_actual_costs: "Total actual costs" total_planned_budget: "Total planned budget" budget_by_cost_type: - title: "Budget by cost type" + title: "Rozpočet podle typu nákladů" blankslate: - heading: "Start project controlling" + heading: "Zahájit projektový controlling" description: "Get an overview of your budgets and costs to efficiently track the health status of your project" blankslate_zero: heading: "Budget details missing" diff --git a/modules/costs/config/locales/crowdin/cs.yml b/modules/costs/config/locales/crowdin/cs.yml index 44770e3f145..2542916b666 100644 --- a/modules/costs/config/locales/crowdin/cs.yml +++ b/modules/costs/config/locales/crowdin/cs.yml @@ -31,9 +31,9 @@ cs: spent: "Strávený čas" spent_on: "Datum" logged_by: "Zadáno" - entity: Přihlášen - entity_id: Přihlášen - entity_gid: Přihlášen + entity: Přihlášen + entity_id: Přihlášen + entity_gid: Přihlášen cost_type: unit: "Název jednotky" unit_plural: "Název Pluralizované jednotky" @@ -61,9 +61,9 @@ cs: start_time: Čas zahájení end_time: Čas dokončení time: Čas - entity: Přihlášen - entity_id: Přihlášen - entity_gid: Přihlášen + entity: Přihlášen + entity_id: Přihlášen + entity_gid: Přihlášen models: time_entry: one: "Vstup času" @@ -239,8 +239,8 @@ cs: actual_costs: title: "Actual costs by month" blankslate: - heading: "Start tracking your time and costs" - description: "Get an overview of your costs and logged time to monitor progress of your project. Make sure that work packages are associated with the correct budget." + heading: "Začněte sledovat svůj čas a náklady" + description: "Získejte přehled o vašich nákladech a zalogovaném čase pro sledování průběhu vašeho projektu. Ujistěte se, že pracovní balíčky jsou přiřazeny ke správnému rozpočtu." action: "Log time" view_details: "View actual costs details" ee: diff --git a/modules/documents/config/locales/crowdin/cs.yml b/modules/documents/config/locales/crowdin/cs.yml index 3fe467eb53a..0151e949282 100644 --- a/modules/documents/config/locales/crowdin/cs.yml +++ b/modules/documents/config/locales/crowdin/cs.yml @@ -78,7 +78,7 @@ cs: description: "The connection to the real-time text collaboration server has been restored." tabs: "Document tabs" index_page: - name: "Název:" + name: "Název" type: "Typ" updated_at: "Last edited" label_legacy: "Legacy" diff --git a/modules/grids/config/locales/crowdin/cs.yml b/modules/grids/config/locales/crowdin/cs.yml index 6bc4da8bcac..e9165943e7d 100644 --- a/modules/grids/config/locales/crowdin/cs.yml +++ b/modules/grids/config/locales/crowdin/cs.yml @@ -5,12 +5,12 @@ cs: empty: "This widget is currently empty." not_available: "This widget is not available." subitems: - title: "Subitems" + title: "Dílčí položky" no_results: "There are no visible children." view_all_subitems: "View all subitems" - button_text: "Subitem" + button_text: "Dílčí položky" members: - title: "Members" + title: "Členové" no_results: "Žádní viditelní členové." view_all_members: "Zobrazit všechny členy" show_members_count: "Show all %{count} members" diff --git a/modules/grids/config/locales/crowdin/js-cs.yml b/modules/grids/config/locales/crowdin/js-cs.yml index a24501fd1df..5f2d3fd11c2 100644 --- a/modules/grids/config/locales/crowdin/js-cs.yml +++ b/modules/grids/config/locales/crowdin/js-cs.yml @@ -29,7 +29,7 @@ cs: finished: 'Dokončeno' discontinued: 'Zrušeno' subprojects: - title: 'Subitems' + title: 'Dílčí položky' project_favorites: title: 'Oblíbené projekty' no_results: 'Momentálně nemáte žádné oblíbené projekty. Klikněte na ikonu hvězdičky v nástěnce projektu pro přidání jednoho do oblíbených.' diff --git a/modules/meeting/config/locales/crowdin/cs.yml b/modules/meeting/config/locales/crowdin/cs.yml index 2dc71cf04e3..62a64364176 100644 --- a/modules/meeting/config/locales/crowdin/cs.yml +++ b/modules/meeting/config/locales/crowdin/cs.yml @@ -220,7 +220,7 @@ cs: text: template: "Tito účastníci budou automaticky pozváni na všechna budoucí zasedání po jejich vytvoření." manage_participants: "Search for and add project members as participants to this meeting." - search_for_members: "Search for project members" + search_for_members: "Vyhledávání členů projektu" blankslate: heading: "Nikdo tu není" description: "There are no participants yet." diff --git a/modules/meeting/config/locales/crowdin/fr.yml b/modules/meeting/config/locales/crowdin/fr.yml index a0351465412..e9c56b16e69 100644 --- a/modules/meeting/config/locales/crowdin/fr.yml +++ b/modules/meeting/config/locales/crowdin/fr.yml @@ -84,7 +84,7 @@ fr: models: recurring_meeting: "Réunion récurrente" meeting: "Réunion ponctuelle" - meeting_agenda_item: "" + meeting_agenda_item: "Point d'ordre du jour" meeting_agenda: "Ordre du jour" meeting_section: "Section" token/ical_meeting: From 89f2f27867fc6f8112f57649b74a703b20594548 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 05:36:39 +0000 Subject: [PATCH 221/435] Bump pagy from 43.3.2 to 43.3.3 Bumps [pagy](https://github.com/ddnexus/pagy) from 43.3.2 to 43.3.3. - [Release notes](https://github.com/ddnexus/pagy/releases) - [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md) - [Commits](https://github.com/ddnexus/pagy/compare/43.3.2...43.3.3) --- updated-dependencies: - dependency-name: pagy dependency-version: 43.3.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b604402f3ee..4f253102c49 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1083,7 +1083,7 @@ GEM ostruct (0.6.3) ox (2.14.23) bigdecimal (>= 3.0) - pagy (43.3.2) + pagy (43.3.3) json uri yaml @@ -2131,7 +2131,7 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 overviews (1.0.0) ox (2.14.23) sha256=4a9aedb4d6c78c5ebac1d7287dc7cc6808e14a8831d7adb727438f6a1b461b66 - pagy (43.3.2) sha256=105683fccb86c1011a96c76da610c35c64aaf4cf412165606aadcb39e733e830 + pagy (43.3.3) sha256=26b822c32ac5452f733736aa0e56bfd45d7fd02358c7d91c7d31bae61164e758 paper_trail (17.0.0) sha256=1c2842061d3874ca7015908e821e2aa14f9b982af2acb2a7974713bf79021c85 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parallel_tests (4.10.1) sha256=df05458c691462b210f7a41fc2651d4e4e8a881e8190e6d1e122c92c07735d70 From 05875dad101c18a6154e8a517aa610f095eb0e25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 05:39:51 +0000 Subject: [PATCH 222/435] Bump lefthook from 2.1.3 to 2.1.4 Bumps [lefthook](https://github.com/evilmartians/lefthook) from 2.1.3 to 2.1.4. - [Release notes](https://github.com/evilmartians/lefthook/releases) - [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md) - [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.3...v2.1.4) --- updated-dependencies: - dependency-name: lefthook dependency-version: 2.1.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b604402f3ee..b316a003c31 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -767,7 +767,7 @@ GEM addressable (~> 2.8) childprocess (~> 5.0) logger (~> 1.6) - lefthook (2.1.3) + lefthook (2.1.4) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -1990,7 +1990,7 @@ CHECKSUMS ladle (1.0.1) sha256=e8586964108c798d48bf57d2a65bd5602e8e5223a176b6602a0fb36c0bda90dc language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e - lefthook (2.1.3) sha256=399eae9411d5a65fbeff38230a685073fefac6ef9ae18165a81f5f7ffa2df7a7 + lefthook (2.1.4) sha256=b3c5bba86911e85b239fea3861ba8c74740fc084ba9ac79dba3fe79267572d6a letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 From 1e373d8ae2145b61615684aa01e89b2b80a49819 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:00:24 +0000 Subject: [PATCH 223/435] Bump oj from 3.16.15 to 3.16.16 Bumps [oj](https://github.com/ohler55/oj) from 3.16.15 to 3.16.16. - [Release notes](https://github.com/ohler55/oj/releases) - [Changelog](https://github.com/ohler55/oj/blob/develop/CHANGELOG.md) - [Commits](https://github.com/ohler55/oj/compare/v3.16.15...v3.16.16) --- updated-dependencies: - dependency-name: oj dependency-version: 3.16.16 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile | 2 +- Gemfile.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index f31dc90f6a1..c3f7959796b 100644 --- a/Gemfile +++ b/Gemfile @@ -124,7 +124,7 @@ gem "sys-filesystem", "~> 1.5.0", require: false gem "bcrypt", "~> 3.1.6" gem "multi_json", "~> 1.19.0" -gem "oj", "~> 3.16.12" +gem "oj", "~> 3.16.16" gem "daemons" gem "good_job", "~> 4.13.3" # update should be done manually in sync with saas-openproject version. diff --git a/Gemfile.lock b/Gemfile.lock index f8b20cdb9be..658917bccfa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -873,7 +873,7 @@ GEM racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) - oj (3.16.15) + oj (3.16.16) bigdecimal (>= 3.0) ostruct (>= 0.2) okcomputer (1.19.1) @@ -1661,7 +1661,7 @@ DEPENDENCIES my_page! net-ldap (~> 0.20.0) nokogiri (~> 1.19.1) - oj (~> 3.16.12) + oj (~> 3.16.16) okcomputer (~> 1.19.1) omniauth! omniauth-openid-connect! @@ -2040,7 +2040,7 @@ CHECKSUMS nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23 - oj (3.16.15) sha256=4d3324cac3e8fef54c0fa250b2af26a16dadd9f9788a1d6b1b2098b793a1b2cd + oj (3.16.16) sha256=3635b36128991796434f55da8decc0de236a323535adcb36fc04e6d0253c013d okcomputer (1.19.1) sha256=7df770e768434816d228407f0786563827cbf34cb379933578829720cb4f1e77 omniauth (1.9.2) omniauth-openid-connect (0.5.0) From a224cabfc1d8cd00ebb13cd1f8f40026ee7c4099 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 08:36:57 +0100 Subject: [PATCH 224/435] Disable SaveBang cop in specs --- spec/.rubocop.yml | 3 +++ spec/models/group_spec.rb | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/.rubocop.yml b/spec/.rubocop.yml index 856439046cd..ff1f8b1ad6d 100644 --- a/spec/.rubocop.yml +++ b/spec/.rubocop.yml @@ -5,3 +5,6 @@ Style/RescueModifier: Rails/FindEach: Enabled: false + +Rails/SaveBang: + Enabled: false diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 5991ba21846..f545db368be 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -78,9 +78,10 @@ RSpec.describe Group do context "if it does not exist" do it "does not create a group user" do count = group.group_users.count - gu = group.group_users.create! user_id: User.maximum(:id).to_i + 1 + gu = group.group_users.create(user_id: User.maximum(:id).to_i + 1) expect(gu).not_to be_valid + expect(gu).not_to be_persisted expect(group.group_users.count).to eq count end end From deb52888365cf5e78e663f1a0e56cacc3c602600 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 09:02:18 +0100 Subject: [PATCH 225/435] Generalize HasPrincipalDetails to HasDetailsTable cocnern --- ...ncipal_details.rb => has_details_table.rb} | 71 +++--- app/models/group.rb | 2 +- app/models/principal.rb | 2 +- .../models/concerns/has_details_table_spec.rb | 222 ++++++++++++++++++ .../concerns/has_principal_details_spec.rb | 178 -------------- 5 files changed, 262 insertions(+), 213 deletions(-) rename app/models/concerns/{has_principal_details.rb => has_details_table.rb} (80%) create mode 100644 spec/models/concerns/has_details_table_spec.rb delete mode 100644 spec/models/concerns/has_principal_details_spec.rb diff --git a/app/models/concerns/has_principal_details.rb b/app/models/concerns/has_details_table.rb similarity index 80% rename from app/models/concerns/has_principal_details.rb rename to app/models/concerns/has_details_table.rb index 41d2fb94b6e..e8b58db734d 100644 --- a/app/models/concerns/has_principal_details.rb +++ b/app/models/concerns/has_details_table.rb @@ -28,15 +28,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module HasPrincipalDetails +module HasDetailsTable extend ActiveSupport::Concern - # Columns on the detail table that are managed automatically - # and should not be delegated to the principal. - DETAIL_INTERNAL_COLUMNS = %w[id principal_id created_at updated_at].freeze - class_methods do - # Declares a detail table for this principal subclass. + # Declares a detail table for this model. # The detail model class is generated automatically — no separate file needed. # # The block is evaluated in the context of the generated detail class, @@ -45,41 +41,37 @@ module HasPrincipalDetails # The back-reference belongs_to, uniqueness constraint, and attribute # delegation are set up automatically. # + # +foreign_key+ defaults to the Rails convention (_id). + # Override for STI or other cases where the FK doesn't match. + # # Example: - # has_principal_details do + # has_details_table do # belongs_to :parent, class_name: "Group", optional: true # validates :parent, presence: true, if: -> { parent_id.present? } # end # - def has_principal_details(&) # rubocop:disable Naming/PredicatePrefix - detail_class = build_detail_class(&) + def has_details_table(foreign_key: "#{model_name.element}_id", &) # rubocop:disable Naming/PredicatePrefix + foreign_key = foreign_key.to_s + + detail_class = build_detail_class(foreign_key, &) association_name = detail_class.name.underscore.to_sym - setup_detail_association(association_name, detail_class) + setup_detail_association(association_name, detail_class, foreign_key) setup_detail_aliases(association_name) - setup_detail_delegation(detail_class) + setup_detail_delegation(detail_class, foreign_key) setup_detail_dup end private - # AR's dup doesn't copy associations, so the detail would be lost. - # Duplicate it so the copy behaves like a normal AR dup with all attributes. - def setup_detail_dup - define_method(:dup) do - super().tap do |copy| - copy.detail = detail.dup if detail.present? - end - end - end - - def build_detail_class(&block) + def build_detail_class(foreign_key, &block) owner_name = model_name.element.to_sym # e.g. :group + fk = foreign_key klass = Class.new(ApplicationRecord) do belongs_to owner_name, inverse_of: :"#{owner_name}_detail", - foreign_key: :principal_id + foreign_key: fk validates owner_name, presence: true, uniqueness: true @@ -90,8 +82,8 @@ module HasPrincipalDetails Object.const_set("#{name}Detail", klass) end - def setup_detail_association(association_name, detail_class) # rubocop:disable Metrics/AbcSize - has_one association_name, foreign_key: :principal_id, + def setup_detail_association(association_name, detail_class, foreign_key) # rubocop:disable Metrics/AbcSize + has_one association_name, foreign_key:, dependent: :destroy, inverse_of: model_name.element.to_sym, class_name: detail_class.name, @@ -104,7 +96,7 @@ module HasPrincipalDetails joins(association_name).where(detail_class.table_name => conditions) } - # Validate the detail record and promote its errors onto the principal + # Validate the detail record and promote its errors onto the owner # so they appear as direct attributes (e.g. group.errors[:parent]). validate do next if detail.nil? || detail.valid? @@ -126,17 +118,28 @@ module HasPrincipalDetails alias_method :build_detail, :"build_#{association_name}" end - def setup_detail_delegation(detail_class) + def setup_detail_delegation(detail_class, foreign_key) # Try to set up delegation eagerly so that writer methods exist # during assign_attributes in new/create. Requires DB + table. if ActiveRecord::Base.connected? && detail_class.table_exists? - finalize_detail_delegation!(detail_class) + finalize_detail_delegation!(detail_class, foreign_key) end # Fallback for when eager setup was skipped (db:create, db:migrate). # finalize_detail_delegation! is idempotent via @_detail_delegation_set_up. + fk = foreign_key after_initialize do - self.class.send(:finalize_detail_delegation!, detail_class) + self.class.send(:finalize_detail_delegation!, detail_class, fk) + end + end + + # AR's dup doesn't copy associations, so the detail would be lost. + # Duplicate it so the copy behaves like a normal AR dup with all attributes. + def setup_detail_dup + define_method(:dup) do + super().tap do |copy| + copy.detail = detail.dup if detail.present? + end end end @@ -151,17 +154,19 @@ module HasPrincipalDetails end end - def finalize_detail_delegation!(detail_class) + def finalize_detail_delegation!(detail_class, foreign_key) return if @_detail_delegation_set_up @_detail_delegation_set_up = true - delegate_detail_columns(detail_class) + delegate_detail_columns(detail_class, foreign_key) delegate_detail_associations(detail_class) end - def delegate_detail_columns(detail_class) - (detail_class.column_names - DETAIL_INTERNAL_COLUMNS).each do |col| + def delegate_detail_columns(detail_class, foreign_key) + internal_columns = %w[id created_at updated_at] + [foreign_key] + + (detail_class.column_names - internal_columns).each do |col| delegate col.to_sym, to: :detail define_detail_writer(:"#{col}=") end diff --git a/app/models/group.rb b/app/models/group.rb index 07ac814278b..c2e36c6f9ae 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -34,7 +34,7 @@ class Group < Principal attr_accessor :hierarchy_depth - has_principal_details do + has_details_table(foreign_key: :principal_id) do belongs_to :parent, class_name: "Group", optional: true validates :parent, presence: true, if: -> { parent_id.present? } diff --git a/app/models/principal.rb b/app/models/principal.rb index 56dd85edac8..adbe8fa58c3 100644 --- a/app/models/principal.rb +++ b/app/models/principal.rb @@ -30,7 +30,7 @@ class Principal < ApplicationRecord include ::Scopes::Scoped - include HasPrincipalDetails + include HasDetailsTable default_scope -> { where.not(status: Principal.statuses[:deleted]) } diff --git a/spec/models/concerns/has_details_table_spec.rb b/spec/models/concerns/has_details_table_spec.rb new file mode 100644 index 00000000000..a6a7407c1a3 --- /dev/null +++ b/spec/models/concerns/has_details_table_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe HasDetailsTable do + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + ActiveRecord::Schema.define do + create_table :test_widgets, force: true do |t| + t.string :name + t.timestamps + end + + create_table :test_widget_details, force: true do |t| + t.references :test_widget, null: false, index: { unique: true } + t.boolean :fancy, default: false, null: false + t.references :related_widget, foreign_key: { to_table: :test_widgets } + t.timestamps + end + end + + klass = Class.new(ApplicationRecord) { self.table_name = "test_widgets" } + Object.const_set(:TestWidget, klass) + klass.include(described_class) + klass.has_details_table do + belongs_to :related_widget, class_name: "TestWidget", optional: true + + validates :related_widget, presence: true, if: -> { related_widget_id.present? } + end + end + + after(:all) do # rubocop:disable RSpec/BeforeAfterAll + Object.send(:remove_const, :TestWidgetDetail) if defined?(TestWidgetDetail) # rubocop:disable RSpec/RemoveConst + Object.send(:remove_const, :TestWidget) if defined?(TestWidget) # rubocop:disable RSpec/RemoveConst + + ActiveRecord::Schema.define do + drop_table :test_widget_details, if_exists: true + drop_table :test_widgets, if_exists: true + end + end + + describe "generated detail class" do + it "creates a named constant for the detail class" do + expect(defined?(TestWidgetDetail)).to eq("constant") + expect(TestWidgetDetail.superclass).to eq(ApplicationRecord) + end + + it "sets up the back-reference belongs_to with conventional FK" do + reflection = TestWidgetDetail.reflect_on_association(:test_widget) + expect(reflection).to be_present + expect(reflection.macro).to eq(:belongs_to) + expect(reflection.foreign_key).to eq("test_widget_id") + end + + it "evaluates the block on the detail class" do + reflection = TestWidgetDetail.reflect_on_association(:related_widget) + expect(reflection).to be_present + expect(reflection.macro).to eq(:belongs_to) + expect(reflection.options[:class_name]).to eq("TestWidget") + end + end + + describe "detail association" do + it "auto-builds a detail record for new instances" do + widget = TestWidget.new(name: "Test") + expect(widget.detail).to be_present + expect(widget.detail).to be_a(TestWidgetDetail) + expect(widget.detail).to be_new_record + end + + it "does not overwrite an existing detail on persisted records" do + widget = TestWidget.create!(name: "Persisted") + detail_id = widget.detail.id + + reloaded = TestWidget.find(widget.id) + expect(reloaded.detail.id).to eq(detail_id) + end + + it "destroys the detail when the owner is destroyed" do + widget = TestWidget.create!(name: "Doomed") + detail_id = widget.detail.id + widget.destroy! + + expect(TestWidgetDetail.find_by(id: detail_id)).to be_nil + end + + it "aliases the concrete association to #detail" do + widget = TestWidget.create!(name: "Aliased") + expect(widget.detail).to eq(widget.test_widget_detail) + end + + it "duplicates the detail when the owner is dup'ed" do + widget = TestWidget.create!(name: "Original", fancy: true) + copy = widget.dup + + expect(copy.detail).to be_present + expect(copy.detail).to be_new_record + expect(copy.detail.id).to be_nil + expect(copy.fancy).to be true + end + end + + describe "attribute delegation" do + let(:widget) { TestWidget.create!(name: "Delegated") } + + it "delegates column readers" do + widget.detail.fancy = true + expect(widget.fancy).to be true + end + + it "delegates column writers" do + widget.fancy = true + expect(widget.detail.fancy).to be true + end + + describe "belongs_to association delegation" do + let(:related) { TestWidget.create!(name: "Related") } + + it "delegates the association reader" do + widget.detail.related_widget = related + expect(widget.related_widget).to eq(related) + end + + it "delegates the association writer" do + widget.related_widget = related + expect(widget.detail.related_widget).to eq(related) + end + + it "delegates the _id reader via column delegation" do + widget.detail.related_widget_id = related.id + expect(widget.related_widget_id).to eq(related.id) + end + + it "delegates the _id writer via column delegation" do + widget.related_widget_id = related.id + expect(widget.detail.related_widget_id).to eq(related.id) + end + end + + it "does not delegate internal columns to the detail" do + widget.detail.update_column(:created_at, 1.day.ago) + expect(widget.created_at).not_to eq(widget.detail.created_at) + end + end + + describe "attribute assignment during creation" do + it "persists detail attributes passed to create" do + created = TestWidget.create!(name: "Creation Test", fancy: true) + expect(created.reload.fancy).to be true + end + + it "persists detail attributes passed to new + save" do + widget = TestWidget.new(name: "New Test", fancy: true) + expect(widget.fancy).to be true + + widget.save! + expect(widget.reload.fancy).to be true + end + + it "persists belongs_to associations passed to create" do + related = TestWidget.create!(name: "Parent Widget") + created = TestWidget.create!(name: "Child Widget", related_widget: related) + + expect(created.reload.related_widget).to eq(related) + end + + it "defaults detail attributes to their column defaults when not specified" do + created = TestWidget.create!(name: "Default Test") + expect(created.reload.fancy).to be false + end + end + + describe "error promotion" do + it "promotes detail validation errors onto the owner" do + I18n.backend.store_translations(:en, + activerecord: { + attributes: { + test_widget_detail: { related_widget: "Related widget" }, + test_widget: { related_widget: "Related widget" } + } + }) + + widget = TestWidget.create!(name: "Error Test") + widget.related_widget_id = 0 + + expect(widget).not_to be_valid + expect(widget.errors[:related_widget]).to be_present + end + + it "is valid when the detail is valid" do + widget = TestWidget.create!(name: "Valid Test") + expect(widget).to be_valid + end + end +end diff --git a/spec/models/concerns/has_principal_details_spec.rb b/spec/models/concerns/has_principal_details_spec.rb deleted file mode 100644 index e7774516dd5..00000000000 --- a/spec/models/concerns/has_principal_details_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" - -RSpec.describe HasPrincipalDetails do - # Test through Group, which is the real consumer of this concern - let(:group) { create(:group) } - - describe "generated detail class" do - it "creates a named constant for the detail class" do - expect(defined?(GroupDetail)).to eq("constant") - expect(GroupDetail.superclass).to eq(ApplicationRecord) - end - - it "sets up the back-reference belongs_to" do - reflection = GroupDetail.reflect_on_association(:group) - expect(reflection).to be_present - expect(reflection.macro).to eq(:belongs_to) - expect(reflection.foreign_key).to eq("principal_id") - end - - it "evaluates the block on the detail class" do - reflection = GroupDetail.reflect_on_association(:parent) - expect(reflection).to be_present - expect(reflection.macro).to eq(:belongs_to) - expect(reflection.options[:class_name]).to eq("Group") - end - end - - describe "detail association" do - it "auto-builds a detail record for new instances" do - new_group = Group.new(lastname: "Test") - expect(new_group.detail).to be_present - expect(new_group.detail).to be_a(GroupDetail) - expect(new_group.detail).to be_new_record - end - - it "does not overwrite an existing detail on persisted records" do - expect(group.detail).to be_persisted - detail_id = group.detail.id - - reloaded = Group.find(group.id) - expect(reloaded.detail.id).to eq(detail_id) - end - - it "destroys the detail when the principal is destroyed" do - detail_id = group.detail.id - group.destroy! - - expect(GroupDetail.find_by(id: detail_id)).to be_nil - end - - it "aliases the concrete association to #detail" do - expect(group.detail).to eq(group.group_detail) - end - - it "duplicates the detail when the principal is dup'ed" do - group.update!(organizational_unit: true) - copy = group.dup - - expect(copy.detail).to be_present - expect(copy.detail).to be_new_record - expect(copy.detail.id).to be_nil - expect(copy.organizational_unit).to be true - end - end - - describe "attribute delegation" do - it "delegates column readers" do - group.detail.organizational_unit = true - expect(group.organizational_unit).to be true - end - - it "delegates column writers" do - group.organizational_unit = true - expect(group.detail.organizational_unit).to be true - end - - describe "belongs_to association delegation" do - let(:parent_group) { create(:group) } - - it "delegates the association reader" do - group.detail.parent = parent_group - expect(group.parent).to eq(parent_group) - end - - it "delegates the association writer" do - group.parent = parent_group - expect(group.detail.parent).to eq(parent_group) - end - - it "delegates the _id reader via column delegation" do - group.detail.parent_id = parent_group.id - expect(group.parent_id).to eq(parent_group.id) - end - - it "delegates the _id writer via column delegation" do - group.parent_id = parent_group.id - expect(group.detail.parent_id).to eq(parent_group.id) - end - end - - it "does not delegate internal columns to the detail" do - # These methods exist on Group itself (from AR), but should not be - # delegated through to the detail record. - group.detail.update_column(:created_at, 1.day.ago) - expect(group.created_at).not_to eq(group.detail.created_at) - end - end - - describe "attribute assignment during creation" do - it "persists detail attributes passed to Group.create" do - created = Group.create!(lastname: "Creation Test", organizational_unit: true) - expect(created.reload.organizational_unit).to be true - end - - it "persists detail attributes passed to Group.new + save" do - new_group = Group.new(lastname: "New Test", organizational_unit: true) - expect(new_group.organizational_unit).to be true - - new_group.save! - expect(new_group.reload.organizational_unit).to be true - end - - it "persists belongs_to associations passed to Group.create" do - parent = create(:group) - created = Group.create!(lastname: "Child Group", parent:) - - expect(created.reload.parent).to eq(parent) - end - - it "defaults detail attributes to their column defaults when not specified" do - created = Group.create!(lastname: "Default Test") - expect(created.reload.organizational_unit).to be false - end - end - - describe "error promotion" do - it "promotes detail validation errors onto the principal" do - group.parent_id = 0 - - expect(group).not_to be_valid - expect(group.errors[:parent]).to be_present - end - - it "is valid when the detail is valid" do - expect(group).to be_valid - end - end -end From 59514f23bfe1a3add9054dd75a57178bed0d405a Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 09:20:11 +0100 Subject: [PATCH 226/435] Add documentation for has_details_table --- .../concepts/has-details-table/README.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/development/concepts/has-details-table/README.md diff --git a/docs/development/concepts/has-details-table/README.md b/docs/development/concepts/has-details-table/README.md new file mode 100644 index 00000000000..99334a85918 --- /dev/null +++ b/docs/development/concepts/has-details-table/README.md @@ -0,0 +1,158 @@ +--- +sidebar_navigation: + title: Detail tables +description: How to use the HasDetailsTable concern to extend a model with extra columns in a separate table +keywords: development concepts, detail tables, HasDetailsTable, STI, model extension +--- + +# Detail tables (HasDetailsTable) + +The `HasDetailsTable` concern (`app/models/concerns/has_details_table.rb`) gives any ActiveRecord model a companion +"detail" table — a 1:1 side table whose columns are transparently delegated back to the owner. From the outside the +model behaves as if the extra columns lived on its own table. + +## Key takeaways + +_HasDetailsTable …_ + +- creates a companion `ApplicationRecord` subclass and registers it as `Detail` (e.g. `GroupDetail`). +- sets up a `has_one` / `belongs_to` pair with `autosave`, `dependent: :destroy`, and a uniqueness constraint. +- delegates every non-internal column (everything except `id`, timestamps, and the FK) as both readers and writers on the owner. +- delegates `belongs_to` associations declared in the block so you can access them directly on the owner (e.g. `group.parent`). +- auto-builds the detail record on `after_initialize` for new records, so `detail` is never `nil`. +- promotes validation errors from the detail onto the owner so they appear as first-class attributes. +- provides `with_detail` and `where_detail` scopes for eager-loading and filtering. +- duplicates the detail when the owner is `dup`'d. + +## When to use + +Use `HasDetailsTable` when you want to extend a model with additional columns **without** adding them to the model's +main table. Typical reasons: + +- **STI models** that need per-subclass attributes. Adding columns to the shared STI table would leave them `NULL` for every other subclass. +- **Optional feature columns** that only a subset of rows will ever populate. +- **Separation of concerns** — keeping the main table focused on core attributes. + +## Why not Rails's DelegatedType? + +Rails provides [`delegated_type`](https://api.rubyonrails.org/classes/ActiveRecord/DelegatedType.html) for a similar-sounding +problem: moving type-specific columns out of a shared table. However, it solves a different shape of problem and doesn't +fit the `HasDetailsTable` use case: + +- **DelegatedType is polymorphic.** It expects the base model to delegate to one of _several_ possible type classes (e.g. an `Entry` can be a `Message` or a `Comment`). `HasDetailsTable` is a fixed 1:1 extension — every `Group` always has exactly one `GroupDetail`. +- **DelegatedType doesn't delegate columns.** You still access attributes through the delegated object (`entry.entryable.body`). `HasDetailsTable` transparently delegates every column so the detail table is invisible to callers (`group.organizational_unit`). +- **No auto-build, error promotion, or dup support.** `DelegatedType` is intentionally minimal. `HasDetailsTable` handles the boilerplate that would otherwise be needed: auto-building on initialize, promoting validation errors, duplicating the detail on `dup`, and providing query scopes. +- **STI conflicts.** `DelegatedType` stores a type/id pair on the base table. For STI models like `Group` (which already has a `type` column on `users`), introducing a second polymorphic type column would be confusing and semantically wrong — the detail isn't a _different type_ of principal, it's _additional data_ for a specific subclass. + +In short: use `DelegatedType` when a base model can delegate to one of many interchangeable types. Use `HasDetailsTable` when a single model needs extra columns in a side table with full transparent access. + +## Basic usage + +Include the concern and call `has_details_table` in your model: + +```ruby +class Widget < ApplicationRecord + include HasDetailsTable + + has_details_table do + # Anything here is evaluated inside the generated WidgetDetail class. + # You can add validations, callbacks, or belongs_to associations. + end +end +``` + +This generates a `WidgetDetail` class backed by the `widget_details` table. Every column in that table (except `id`, +`widget_id`, `created_at`, `updated_at`) is delegated to `Widget`, so you can read and write them directly: + +```ruby +widget = Widget.new(some_detail_column: "value") +widget.some_detail_column # => "value" +widget.detail # => # +``` + +## What it sets up automatically + +| Feature | Details | +| ---------------------- | --------------------------------------------------------------------------------------- | +| Detail class | `Detail` constant, subclass of `ApplicationRecord` | +| Association | `has_one :_detail` on owner, `belongs_to :` on detail | +| Aliases | `detail` / `detail=` / `build_detail` point to the association | +| Column delegation | Readers delegated via `delegate`, writers via custom methods that auto-build the detail | +| Association delegation | `belongs_to` associations declared in the block are delegated (both object and `_id`) | +| Auto-build | `after_initialize` builds the detail for new records | +| Error promotion | Detail validation errors are copied onto the owner | +| Dup support | `dup` on the owner also duplicates the detail | +| `with_detail` scope | `joins` + `includes` for eager loading | +| `where_detail` scope | `joins` + `where` for filtering by detail columns | +| Nested attributes | `accepts_nested_attributes_for` is called automatically | + +## Custom foreign key (STI) + +When the model uses STI, the FK column won't match the model name. For example, `Group` inherits from `Principal` +(stored in the `users` table), so the FK is `principal_id`, not `group_id`: + +```ruby +class Group < Principal + include HasDetailsTable + + has_details_table(foreign_key: :principal_id) do + belongs_to :parent, class_name: "Group", optional: true + validates :parent, presence: true, if: -> { parent_id.present? } + end +end +``` + +The corresponding `group_details` table uses `principal_id` as its FK column: + +```ruby +create_table :group_details do |t| + t.references :principal, null: false, + foreign_key: { to_table: :users }, + index: { unique: true } + t.boolean :organizational_unit, default: false, null: false + t.references :parent, foreign_key: { to_table: :users } + + t.timestamps +end +``` + +## Database table conventions + +The detail table must follow these conventions: + +| Convention | Example (`Widget`) | +| ---------------- | -------------------------------------------- | +| Table name | `widget_details` | +| FK column | `widget_id` (or custom, e.g. `principal_id`) | +| Required columns | FK (non-null), `created_at`, `updated_at` | +| Unique index | On the FK column (enforces 1:1) | + +The concern reads the detail table's columns at load time to set up delegation, so **the migration must run before the model is loaded**. In practice this means the migration should exist before or alongside the code change — standard Rails migration ordering. + +## Adding associations to the detail + +Declare `belongs_to` associations inside the block. They are evaluated on the detail class, but delegated to the owner: + +```ruby +has_details_table do + belongs_to :parent, class_name: "Group", optional: true +end +``` + +This lets you write: + +```ruby +group.parent # delegated to group.detail.parent +group.parent = other # delegated, auto-builds detail if needed +group.parent_id # delegated via column delegation +group.parent_id = 42 # delegated via column writer +``` + +The back-reference from the details table to the owner (`belongs_to :group` / `belongs_to :principal`) is set up automatically — don't declare it yourself. + +## Gotchas + +- **Migration ordering**: The detail table must exist before the model class loads. If `has_details_table` runs and the table doesn't exist yet, column delegation is deferred to `after_initialize`. This works at runtime but means delegation won't be available at class-load time during `db:migrate`. +- **Writers auto-build**: Custom writer methods call `build_detail` if `detail` is `nil`. This means `assign_attributes` works correctly even before `after_initialize` fires (e.g. in `Model.new(attrs)`). +- **Error promotion**: Validation errors from the detail appear on the owner with the detail's attribute name. If the detail validates `:parent`, the owner will have an error on `:parent`. +- **Uniqueness**: The generated detail class validates uniqueness of the owner association. Combined with the unique DB index this guarantees exactly one detail row per owner. From c339051d13fd09d08cd6d182b7315b83cb20f4f4 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 09:21:41 +0100 Subject: [PATCH 227/435] Update app/models/groups/hierarchy.rb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oliver Günther --- app/models/groups/hierarchy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/groups/hierarchy.rb b/app/models/groups/hierarchy.rb index 17dc857efa6..3af58b2a189 100644 --- a/app/models/groups/hierarchy.rb +++ b/app/models/groups/hierarchy.rb @@ -73,7 +73,7 @@ module Groups::Hierarchy # Returns all groups in depth-first tree order, alphabetical within each level. # Each group has its `hierarchy_depth` set to its nesting level (0 for roots). def in_tree_order - all_groups = with_detail.order(Arel.sql("lastname ASC")).to_a + all_groups = with_detail.order(:lastname).to_a children_by_parent = all_groups.group_by(&:parent_id) walk_tree(children_by_parent, nil, 0) end From 2338b5856198242861f9c084664263f4e86d9225 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Wed, 18 Mar 2026 09:47:17 +0100 Subject: [PATCH 228/435] Fix overwriting Traefik configuration It turns out that the initial approach of overwriting certain configuration from the command-line did not work at all, because Traefik insists on only receiving configuration in one way. Either of config file, command-line or env variables. The best thing to overwrite separately in a docker setup is environment variables, so the configuration has been turned towards environment variables now. --- .../tls/docker-compose.override.example.yml | 12 +++--- docker/dev/tls/docker-compose.yml | 14 ++++++- docker/dev/tls/traefik.yaml | 39 ------------------- 3 files changed, 18 insertions(+), 47 deletions(-) delete mode 100644 docker/dev/tls/traefik.yaml diff --git a/docker/dev/tls/docker-compose.override.example.yml b/docker/dev/tls/docker-compose.override.example.yml index f2f6b439f5f..20bb3767022 100644 --- a/docker/dev/tls/docker-compose.override.example.yml +++ b/docker/dev/tls/docker-compose.override.example.yml @@ -1,13 +1,11 @@ services: traefik: - # Overwrite to enable Let's encrypt instead of using Step CA for certificate generation - # command: > - # --entryPoints.websecure.http.tls.certresolver=letsencrypt - # --certificatesresolvers.letsencrypt.acme.email=you@example.com - - # For step CA only: Overwrite trusted CA certificates with Step root CA (not needed for Let's encrypt) environment: - - LEGO_CA_CERTIFICATES=/step/certs/root_ca.crt + # For step CA only: Overwrite trusted CA certificates with Step root CA (not needed for Let's encrypt) + LEGO_CA_CERTIFICATES: /step/certs/root_ca.crt + # Overwrite to enable Let's encrypt instead of using Step CA for certificate generation + # TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS_CERTRESOLVER: letsencrypt + # TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL: you@example.com # Necessary for certificates via Step CA only depends_on: diff --git a/docker/dev/tls/docker-compose.yml b/docker/dev/tls/docker-compose.yml index a75a3773a94..05bc676893c 100644 --- a/docker/dev/tls/docker-compose.yml +++ b/docker/dev/tls/docker-compose.yml @@ -5,11 +5,23 @@ services: - "80:80" - "443:443" volumes: - - ./traefik.yaml:/etc/traefik/traefik.yaml:ro - /var/run/docker.sock:/var/run/docker.sock - ./acme.json:/acme.json - step:/step:ro restart: unless-stopped + environment: + TRAEFIK_LOG_LEVEL: INFO + TRAEFIK_API_DISABLEDASHBOARDAD: true + TRAEFIK_PROVIDERS_DOCKER_NETWORK: gateway + TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT: false + TRAEFIK_ENTRYPOINTS_WEB_ADDRESS: ":80" + TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_TO: websecure + TRAEFIK_ENTRYPOINTS_WEBSECURE_ADDRESS: ":443" + TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS_CERTRESOLVER: step + TRAEFIK_CERTIFICATESRESOLVERS_STEP_ACME_CASERVER: https://step:9000/acme/acme/directory + TRAEFIK_CERTIFICATESRESOLVERS_STEP_ACME_TLSCHALLENGE: true + TRAEFIK_CERTIFICATESRESOLVERS_STEP_ACME_EMAIL: root@localhost + TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT: web networks: external: aliases: diff --git a/docker/dev/tls/traefik.yaml b/docker/dev/tls/traefik.yaml deleted file mode 100644 index 7f19e16d581..00000000000 --- a/docker/dev/tls/traefik.yaml +++ /dev/null @@ -1,39 +0,0 @@ -log: - level: INFO - -api: - dashboard: true - disabledashboardad: true - -providers: - docker: - network: gateway - exposedByDefault: false - -entryPoints: - web: - address: ":80" - http: - redirections: - entrypoint: - to: websecure - websecure: - address: ":443" - http: - tls: - certresolver: step # Using step by default, overwritable via CLI - -certificatesresolvers: - step: - acme: - caserver: https://step:9000/acme/acme/directory - tlschallenge: true - email: root@localhost - keytype: RSA4096 - storage: acme.json - letsencrypt: - acme: - keytype: RSA4096 - storage: acme.json - httpChallenge: - entryPoint: web From f2410d0b438fc2ffb92b229b5817c1c9d445d3c1 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 18 Mar 2026 10:08:11 +0100 Subject: [PATCH 229/435] Add comment fields to the displayFields in case a user w/o permissions opens the dialog to see the comment in readonly mode --- .../inplace_edit_field_component.html.erb | 2 +- .../common/inplace_edit_field_component.rb | 28 ++++++++++++-- ...place_edit_field_dialog_component.html.erb | 26 +++++++------ .../inplace_edit_field_dialog_component.rb | 4 ++ .../calculated_value_input_component.rb | 5 ++- .../display_field_component.html.erb | 13 +++++++ .../display_fields/display_field_component.rb | 38 ++++++++++++++----- .../display_fields_component.sass | 11 +++++- .../patterns/06-inplace-edit-fields.md.erb | 21 ++++++---- .../inplace_edit_field_component_spec.rb | 4 +- .../calculated_value_input_component_spec.rb | 2 +- .../display_field_component_spec.rb | 4 +- .../overview_page/inputs_spec.rb | 3 +- 13 files changed, 119 insertions(+), 42 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_field_component.html.erb b/app/components/open_project/common/inplace_edit_field_component.html.erb index e11f8850545..cfc9f2fa47e 100644 --- a/app/components/open_project/common/inplace_edit_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_component.html.erb @@ -9,7 +9,7 @@ inplace_edit_system_arguments: @system_arguments.to_json } ) do %> - <% if display_field_component.present? && !enforce_edit_mode %> + <% if display_field_component.present? && (!enforce_edit_mode || !writable?) %> <%= render display_field_component %> <% else %> <%= primer_form_with(**form_options) do |form| diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index 40ce1100513..ec1f2818c44 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -85,8 +85,19 @@ module OpenProject return nil if display_field_class.nil? @display_field_component ||= begin + has_comment = custom_field? && custom_field&.has_comment? additional_args = open_in_dialog? ? dialog_display_arguments : {} - display_field_class.new(model:, attribute:, writable: writable?, truncated:, **@system_arguments.merge(additional_args)) + display_field_class.new( + model:, + attribute:, + writable: writable?, + truncated:, + has_comment:, + # Show comment as read-only text when a non-writable user opens the dialog. + # enforce_edit_mode identifies the dialog context. + show_comment: enforce_edit_mode && !writable? && has_comment, + **@system_arguments.merge(additional_args) + ) end end @@ -139,7 +150,10 @@ module OpenProject model: model.class.name, id: model.id, attribute:, - system_arguments_json: @system_arguments.except(:id).merge(page_component_id: @system_arguments[:id]).to_json + system_arguments_json: @system_arguments + .except(:id) + .merge(page_component_id: @system_arguments[:id], writable: writable?) + .to_json ) end @@ -149,13 +163,21 @@ module OpenProject private - def dialog_display_arguments + def dialog_trigger_arguments { dialog_controller_name: "inplace-edit", dialog_url: dialog_edit_url } end + # When inside a dialog and the field is not writable, strip dialog trigger args + # to prevent opening a nested dialog from the display component. + def dialog_display_arguments + return {} if enforce_edit_mode && !writable? + + dialog_trigger_arguments + end + def writable? return @writable if defined?(@writable) diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb index 87aac78b03f..be5bf2bd879 100644 --- a/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb @@ -2,7 +2,7 @@ render( Primer::Alpha::Dialog.new( title: dialog_title, - classes: "Overlay--size-large-portrait", + classes: "Overlay--size-large-portrait op-inplace-edit--dialog", size: :large, id: dialog_id ) @@ -17,18 +17,20 @@ footer_collection.with_component( Primer::Beta::Button.new(data: { "close-dialog-id": dialog_id }) ) do - t("button_cancel") + writable? ? t("button_cancel") : t("button_close") end - footer_collection.with_component( - Primer::Beta::Button.new(scheme: :primary, - type: :submit, - form: form_id, - data: { - test_selector: "save-inplace-edit-field-button", - turbo: true - }) - ) do - t("button_save") + if writable? + footer_collection.with_component( + Primer::Beta::Button.new(scheme: :primary, + type: :submit, + form: form_id, + data: { + test_selector: "save-inplace-edit-field-button", + turbo: true + }) + ) do + t("button_save") + end end end end diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.rb b/app/components/open_project/common/inplace_edit_field_dialog_component.rb index 7c99b04cc03..98acf2c19b8 100644 --- a/app/components/open_project/common/inplace_edit_field_dialog_component.rb +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.rb @@ -43,6 +43,10 @@ module OpenProject private + def writable? + @system_arguments[:writable] == true + end + def dialog_title @system_arguments[:label] || @model.class.human_attribute_name(@attribute) end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb index be3f4ca2192..800321c7a16 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb @@ -38,8 +38,9 @@ module OpenProject attr_reader :model, :attribute - def initialize(model:, attribute:, writable: nil, truncated: false, **system_arguments) - super(model:, attribute:, writable: false, truncated:, **system_arguments) + def initialize(model:, attribute:, writable: nil, truncated: false, has_comment: false, show_comment: false, + **system_arguments) + super(model:, attribute:, writable: false, truncated:, has_comment:, show_comment:, **system_arguments) end def render_calculation_error diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb index 3f452c3bdad..1c3e86cc295 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.html.erb @@ -30,6 +30,19 @@ ) end + if show_comment? + flex.with_row(w: :full, mt: 2) do + flex_layout do |inner_flex| + inner_flex.with_row do + render(Primer::Beta::Text.new(tag: :label, font_weight: :bold)) { t("attributes.comment") } + end + inner_flex.with_row(mt: 1) do + render(Primer::Beta::Text.new) { comment_text } + end + end + end + end + if (error_html = render_calculation_error).present? flex.with_row(w: :full) { error_html } end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 9b181147068..c46ec9fbe8a 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -37,12 +37,14 @@ module OpenProject attr_reader :model, :attribute, :writable, :truncated - def initialize(model:, attribute:, writable:, truncated:, **system_arguments) + def initialize(model:, attribute:, writable:, truncated:, has_comment: false, show_comment: false, **system_arguments) super() @model = model @attribute = attribute @writable = writable @truncated = truncated + @has_comment = has_comment + @show_comment = show_comment @system_arguments = system_arguments end @@ -54,11 +56,7 @@ module OpenProject elsif value.is_a?(Date) || value.is_a?(Time) helpers.format_date(value) elsif value.present? && value != [nil] - if custom_field? - helpers.format_value(value, custom_field) - else - value.to_s - end + format_present_value(value) else t("placeholders.default") end @@ -78,7 +76,7 @@ module OpenProject def base_arguments { - classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable?}", + classes: display_field_classes, id: @system_arguments[:id], role: "button", tabindex: 0 @@ -86,7 +84,7 @@ module OpenProject end def dialog_field_arguments - return {} unless writable? + return {} unless writable? || @has_comment { data: { @@ -121,6 +119,10 @@ module OpenProject # no-op — subclasses may override to render a calculation error row end + def show_comment? + @show_comment + end + def input_specific_call render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do render_display_value @@ -143,6 +145,24 @@ module OpenProject private + def display_field_classes + # The later check catches non-editable users which should still see the comment in a dialog + clickable = writable? || open_in_dialog? + "op-inplace-edit--display-field#{' op-inplace-edit--display-field_clickable' if clickable}" + end + + def format_present_value(value) + if custom_field? + helpers.format_value(value, custom_field) + else + value.to_s + end + end + + def comment_text + model.custom_comment_for(custom_field)&.text.presence || t("placeholders.default") + end + def edit_url inplace_edit_field_edit_path( model: model.class.name, @@ -171,7 +191,7 @@ module OpenProject end def dialog_controller_actions - return "" unless writable? + return "" unless writable? || @has_comment "click->inplace-edit#openDialog " \ "keydown.enter->inplace-edit#openDialog " \ diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass b/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass index 8a2b7c9129d..a0c26e0c968 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass @@ -8,5 +8,14 @@ border-color: var(--borderColor-default) box-shadow: var(--shadow-inset) - &:not(&_editable) + &:not(&_clickable) cursor: not-allowed + &:hover, &:focus + background-color: var(--bgColor-muted) + + &--dialog + .op-inplace-edit--display-field + &:not(&_clickable) + &:hover, &:focus + border-color: transparent + box-shadow: none diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index 99858232318..22d7125c5a3 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -45,7 +45,7 @@ The component resolves the edit field via the `FieldRegistry` and optionally a d |---|---|---| | `model` | — | The ActiveRecord model instance | | `attribute` | — | The attribute name as a symbol | -| `enforce_edit_mode` | `false` | Always render in edit mode, skip display component | +| `enforce_edit_mode` | `false` | Render in edit mode when writable. Non-writable users always see the display component, even with `enforce_edit_mode: true`. Used by the dialog to skip the display→edit click. | | `open_in_dialog` | `false` | Force edit form to open in a dialog | | `show_action_buttons` | `true` | Show save/cancel buttons inside the field component | | `truncated` | `false` | Pass truncation hint to the display component | @@ -62,7 +62,7 @@ This allows individual field components to declare that they always require a di **Simplified HTML of the `InplaceEditFieldComponent`:** ```html <%= component_wrapper(tag: :div, class: "op-inplace-edit") do - if display_field_component.present? && !enforce_edit_mode + if display_field_component.present? && (!enforce_edit_mode || !writable?) render display_field_component else primer_form_with( @@ -308,9 +308,12 @@ end When a field is configured to open in dialog mode, the display component renders a trigger button instead of a direct click target. Clicking the button fetches and opens the `InplaceEditFieldDialogComponent` via a lazy-loaded Turbo request. -The dialog wraps an `InplaceEditFieldComponent` in `enforce_edit_mode: true` and renders save/cancel buttons in its footer. The edit form inside the dialog is linked to the footer buttons via a shared `form_id`. +The dialog wraps an `InplaceEditFieldComponent` in `enforce_edit_mode: true`. The body and footer buttons differ based on writability: -This is used automatically for custom fields that have comments enabled, ensuring the comment textarea is presented together with the value in a dialog rather than inline. +- **Writable user**: the edit form is shown with Save and Cancel buttons. +- **Non-writable user**: the display component is shown (read-only value, and comment as plain text if `has_comment?`) with only a Close button. + +The `writable` flag is computed once when building the dialog URL and embedded in `system_arguments_json`, so the dialog component does not need to re-query the registry. ``` Display component (with dialog trigger) @@ -318,10 +321,12 @@ Display component (with dialog trigger) └─ InplaceEditFieldDialogComponent ├─ Primer::Alpha::Dialog │ ├─ body: InplaceEditFieldComponent (enforce_edit_mode: true) - │ │ └─ EditFieldComponent - │ │ ├─ value field - │ │ └─ comment textarea (if has_comment?) - │ └─ footer: Save / Cancel buttons + │ │ ├─ [writable] EditFieldComponent + │ │ │ ├─ value field + │ │ │ └─ comment textarea (if has_comment?) + │ │ └─ [non-writable] DisplayFieldComponent + │ │ └─ comment as text (if has_comment?) + │ └─ footer: [writable] Save / Cancel — [non-writable] Close ``` **`page_component_id` convention:** diff --git a/spec/components/open_project/common/inplace_edit_field_component_spec.rb b/spec/components/open_project/common/inplace_edit_field_component_spec.rb index ae4d6601190..7dabc87d5d3 100644 --- a/spec/components/open_project/common/inplace_edit_field_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_field_component_spec.rb @@ -73,7 +73,7 @@ RSpec.describe OpenProject::Common::InplaceEditFieldComponent, type: :component render_inline(described_class.new(model: project, attribute: :description, update_registry:)) expect(rendered_content) - .to have_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_editable") + .to have_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_clickable") end it "renders edit field when enforce_edit_mode is true" do @@ -100,7 +100,7 @@ RSpec.describe OpenProject::Common::InplaceEditFieldComponent, type: :component expect(rendered_content) .not_to include("click->inplace-edit#request") expect(rendered_content) - .to have_no_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_editable") + .to have_no_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_clickable") end end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb index 3870988afca..ded91b54500 100644 --- a/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component_spec.rb @@ -41,7 +41,7 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::Calculated ) expect(rendered_content).not_to include("click->inplace-edit#request") - expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_editable") + expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_clickable") end it "renders the not-editable tooltip" do diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb index 5e9442e66ce..bd73c601b5e 100644 --- a/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/display_field_component_spec.rb @@ -68,14 +68,14 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::DisplayFie it "marks the display field as editable when writable" do render_inline(described_class.new(model: project, attribute: :name, writable: true, truncated: false)) - expect(rendered_content).to have_css(".op-inplace-edit--display-field_editable") + expect(rendered_content).to have_css(".op-inplace-edit--display-field_clickable") expect(rendered_content).to include("click->inplace-edit#request") end it "does not mark the display field as editable when not writable" do render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false)) - expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_editable") + expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_clickable") expect(rendered_content).not_to include("click->inplace-edit#request") end end diff --git a/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb index 72c016d49fe..379604d0f8d 100644 --- a/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb @@ -81,7 +81,8 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to value_expectation end - expect(page).to have_field("Comment", with: "baz", readonly: true) + expect(page).to have_text("Comment") + expect(page).to have_text("baz") end end end From 2e8a7d97ce3dfb42af4490a7d35e510f9b73faba Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad Date: Wed, 18 Mar 2026 10:19:30 +0100 Subject: [PATCH 230/435] Set the tab content display to inline in user settings --- .../users/non_working_times/calendar_component.sass | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/components/users/non_working_times/calendar_component.sass b/app/components/users/non_working_times/calendar_component.sass index 42424344f1a..74168254353 100644 --- a/app/components/users/non_working_times/calendar_component.sass +++ b/app/components/users/non_working_times/calendar_component.sass @@ -4,6 +4,10 @@ @media (max-width: $breakpoint-sm) height: 100% +// Set the display of calendar container +#tab-content-non_working_times + display: inline + .users-non-working-times-calendar-view height: 100% @@ -37,3 +41,5 @@ &.fc-day-today background-color: var(--bgColor-accent-muted) !important color: var(--fgColor-accent) !important + + From 1902ec436cb7eb041e763e1920852764e6949696 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:43:59 +0200 Subject: [PATCH 231/435] Split the root Agent.md files into subdirectories. --- AGENTS.md | 196 +++++-------------------------------------- app/AGENTS.md | 42 ++++++++++ app/CLAUDE.md | 1 + config/AGENTS.md | 14 ++++ config/CLAUDE.md | 1 + db/AGENTS.md | 30 +++++++ db/CLAUDE.md | 1 + docker/dev/AGENTS.md | 61 ++++++++++++++ docker/dev/CLAUDE.md | 1 + frontend/AGENTS.md | 46 ++++++++++ frontend/CLAUDE.md | 1 + spec/AGENTS.md | 32 +++++++ spec/CLAUDE.md | 1 + 13 files changed, 250 insertions(+), 177 deletions(-) create mode 100644 app/AGENTS.md create mode 120000 app/CLAUDE.md create mode 100644 config/AGENTS.md create mode 120000 config/CLAUDE.md create mode 100644 db/AGENTS.md create mode 120000 db/CLAUDE.md create mode 100644 docker/dev/AGENTS.md create mode 120000 docker/dev/CLAUDE.md create mode 100644 frontend/AGENTS.md create mode 120000 frontend/CLAUDE.md create mode 100644 spec/AGENTS.md create mode 120000 spec/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index dac00fe3410..27510a06d32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,6 @@ - Node: `^22.21.0` (see `package.json` engines) - Bundler: Latest 2.x -OpenProject supports two development setups: **Local** and **Docker**. Choose one based on your preference. - ### Local Development Setup ```bash @@ -34,59 +32,24 @@ bin/dev # Start all services (Rails, frontend, Good Job ### Docker Development Setup -The Docker development environment uses configurations in `docker/dev/` and the `bin/compose` wrapper script. - -```bash -# Initial setup (first time only) -bin/compose setup # Installs backend and frontend dependencies - -# Starting services -bin/compose start # Start backend and frontend in background -bin/compose run # Start frontend in background, backend in foreground (for debugging with pry) - -# Running tests -bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container - -# Other operations -bin/compose reset # Remove all containers and volumes (requires setup again) -bin/compose # Pass any docker-compose command directly -``` - -**Important Docker Notes:** -- **CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it) -- Most developers use a local `docker-compose.override.yml` for custom port mappings and configurations -- Copy `docker-compose.override.example.yml` to `docker-compose.override.yml` and customize as needed -- Default ports: Backend at http://localhost:3000 (or 4200 for frontend dev server) -- Services: `backend`, `frontend`, `worker`, `db`, `db-test`, `backend-test`, `cache` -- Persisted volumes: `pgdata`, `bundle`, `npm`, `tmp`, `opdata` (data survives container restarts) -- Docker build context: Uses Dockerfiles in `docker/dev/backend/` and `docker/dev/frontend/` +See [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md) for full Docker setup and commands. ## Project Structure ### Key Directories -- `app/` - Rails application code - - `app/components/` - ViewComponent-based UI components (Ruby + ERB) - - `app/contracts/` - Validation and authorization contracts - - `app/controllers/` - Rails controllers - - `app/models/` - ActiveRecord models - - `app/services/` - Service objects (business logic) - - `app/workers/` - Background job workers -- `config/` - Rails configuration, routes, locales -- `db/` - Database migrations and seeds -- `frontend/src/` - Frontend code - - `frontend/src/app/` - Legacy Angular modules/components - - `frontend/src/stimulus/` - Stimulus controllers - - `frontend/src/turbo/` - Turbo integration -- `lib/` - Ruby libraries and extensions -- `lookbook/` - ViewComponent previews (https://qa.openproject-edge.com/lookbook/) -- `modules/` - OpenProject plugin modules -- `spec/` - RSpec test suite - - `spec/features/` - System/feature tests (Capybara) - - `spec/models/` - Model unit tests - - `spec/requests/` - API/integration tests - - `spec/services/` - Service tests + +- `app/` — Rails application code (see [`app/AGENTS.md`](app/AGENTS.md)) +- `config/` — Rails configuration, routes, locales (see [`config/AGENTS.md`](config/AGENTS.md)) +- `db/` — Database migrations and seeds (see [`db/AGENTS.md`](db/AGENTS.md)) +- `docker/dev/` — Docker development environment (see [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md)) +- `frontend/` — TypeScript/Angular/Stimulus frontend (see [`frontend/AGENTS.md`](frontend/AGENTS.md)) +- `lib/` — Ruby libraries and extensions +- `lookbook/` — ViewComponent previews () +- `modules/` — OpenProject plugin modules +- `spec/` — RSpec test suite (see [`spec/AGENTS.md`](spec/AGENTS.md)) ### Configuration Files + - `.ruby-version` - Ruby version - `.rubocop.yml` - Ruby linting rules - `.erb_lint.yml` - ERB template linting @@ -95,144 +58,23 @@ bin/compose # Pass any docker-compose command dire - `package.json` / `frontend/package.json` - Node.js dependencies - `lefthook.yml` - Git hooks configuration -## Building and Testing - ### Linting (Run Before Committing) ```bash -# Ruby -bundle exec rubocop # Check all files -bin/dirty-rubocop --uncommitted # Check only uncommitted changes - -# JavaScript/TypeScript -cd frontend && npx eslint src/ && cd .. - -# ERB Templates -erb_lint {files} - # Install Git Hooks (recommended) -bundle exec lefthook install +bundle exec lefthook install # Install Git hooks (run once after cloning) ``` -### Running Tests +## Commit Messages -```bash -# Backend (RSpec) - prefer specific tests over running all -bundle exec rspec spec/models/user_spec.rb # Single file -bundle exec rspec spec/models/user_spec.rb:42 # Single line -bundle exec rspec spec/features # Directory -bundle exec rake parallel:spec # Parallel execution - -# Frontend (Jasmine/Karma) -cd frontend && npm test && cd .. -``` - -### Debugging CI Failures -```bash -./script/github_pr_errors | xargs bundle exec rspec # Run failed tests from CI -./script/bulk_run_rspec spec/path/to/flaky_spec.rb # Run tests multiple times -``` - -## Code Style Guidelines - -### Ruby -- Follow [Ruby community style guide](https://github.com/bbatsov/ruby-style-guide) -- Use service objects for complex business logic (return `ServiceResult`) -- Use contracts for validation and authorization -- Keep controllers thin, models focused -- Document with [YARD](https://yardoc.org/) -- Write RSpec tests for all new features - -### JavaScript/TypeScript -- **New development**: Use Hotwire (Turbo + Stimulus) with server-rendered HTML -- **Legacy code**: Follow ESLint rules -- Prefer TypeScript over JavaScript -- Use [Primer Design System](https://primer.style/product/) via ViewComponent - -### Templates -- Use ERB for server-rendered views -- Use ViewComponents for reusable UI (with Lookbook previews) -- Lint with erb_lint before committing - -### Database Migrations -- Follow Rails migration conventions -- Migrations are "squashed" between major releases (see `docs/development/migrations/`) - -### Translations -- UI strings must use translation keys (never hard-coded) -- Source translations in `**/config/locales/en.yml` can be modified directly -- Other translations managed via Crowdin - -### Commit Messages - First line: < 72 characters, then blank line, then detailed description - Reference work packages when applicable - Merge strategy: "Merge pull request" (not squash), except single-commit PRs can use "Rebase and merge" -## Important Commands Reference - -### Local Development Commands - -```bash -# Setup -bin/setup # Initial Rails setup -bin/setup_dev # Full dev environment setup - -# Database -bundle exec rails g migration MigrationName # Generate a migration -bundle exec rails db:migrate # Run migrations -bundle exec rails db:rollback # Rollback last migration -bundle exec rails db:seed # Seed sample data - -# Development -bin/dev # Start all services -bundle exec rails console # Rails console -bundle exec rails routes # List routes - -# Testing -bundle exec rspec # Run RSpec tests -bundle exec rails parallel:spec # Parallel tests -cd frontend && npm test # Frontend tests - -# Linting -bundle exec rubocop # Ruby linting -cd frontend && npx eslint src/ # JS/TS linting -erb_lint {files} # ERB linting -``` - -### Docker Development Commands - -```bash -# Setup and lifecycle -bin/compose setup # Setup Docker environment (first time) -bin/compose start # Start all services in background -bin/compose run # Start frontend in background, backend in foreground -bin/compose reset # Remove all containers and volumes -bin/compose stop # Stop all services -bin/compose down # Stop and remove containers - -# Testing -bin/compose rspec spec/models/user_spec.rb # Run specific tests -bin/compose exec backend bundle exec rspec # Run tests directly in backend container - -# Development -bin/compose exec backend bundle exec rails console # Rails console -bin/compose logs backend # View backend logs -bin/compose logs -f backend # Follow backend logs -bin/compose ps # List running containers - -# Database -bin/compose exec backend bundle exec rails db:migrate # Run migrations -bin/compose exec backend bundle exec rails db:seed # Seed data - -# Direct docker-compose commands -bin/compose up -d # Start services -bin/compose restart backend # Restart backend service -``` - ## Additional Documentation -- `docs/development/` - Development documentation -- `docs/development/running-tests/` - Testing guide -- `docs/development/code-review-guidelines/` - Code review standards -- `CONTRIBUTING.md` - Contribution workflow -- `.github/copilot-instructions.md` - Extended agent instructions with troubleshooting +- `docs/development/` — Development documentation +- `docs/development/running-tests/` — Testing guide +- `docs/development/code-review-guidelines/` — Code review standards +- `CONTRIBUTING.md` — Contribution workflow +- `.github/copilot-instructions.md` — Extended agent instructions with troubleshooting diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 00000000000..0b420cfa791 --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,42 @@ +# App + +## Directory Structure + +- `app/components/` - ViewComponent-based UI components (Ruby + ERB) +- `app/contracts/` - Validation and authorization contracts +- `app/controllers/` - Rails controllers +- `app/models/` - ActiveRecord models +- `app/services/` - Service objects (business logic) +- `app/workers/` - Background job workers + +## Code Style + +### Ruby + +- Follow [Ruby community style guide](https://github.com/bbatsov/ruby-style-guide) +- Use service objects for complex business logic (return `ServiceResult`) +- Use contracts for validation and authorization +- Keep controllers thin, models focused +- Document with [YARD](https://yardoc.org/) +- Write RSpec tests for all new features + +### Templates + +- Use ERB for server-rendered views +- Use ViewComponents for reusable UI (with Lookbook previews) +- Lint with erb_lint before committing + +## Translations + +- UI strings must use translation keys (never hard-coded) + +## Linting + +```bash +# Ruby +bundle exec rubocop # Check all files +bin/dirty-rubocop --uncommitted # Check only uncommitted changes + +# ERB Templates +erb_lint {files} +``` diff --git a/app/CLAUDE.md b/app/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/app/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/config/AGENTS.md b/config/AGENTS.md new file mode 100644 index 00000000000..c2f55641e23 --- /dev/null +++ b/config/AGENTS.md @@ -0,0 +1,14 @@ +# Config + +## Translations + +- UI strings must use translation keys (never hard-coded) +- Source translations in `**/config/locales/en.yml` can be modified directly +- Other translations managed via Crowdin + +```bash +bundle exec i18n-tasks missing # Show missing translation keys +bundle exec i18n-tasks unused # Show unused translation keys +bundle exec i18n-tasks normalize # Fix/normalize translation files +bundle exec i18n-tasks check-consistent-interpolations # Check interpolation consistency +``` diff --git a/config/CLAUDE.md b/config/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/config/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/db/AGENTS.md b/db/AGENTS.md new file mode 100644 index 00000000000..a80ff4f767b --- /dev/null +++ b/db/AGENTS.md @@ -0,0 +1,30 @@ +# Database + +## Code Style + +### Database Migrations + +- Follow Rails migration conventions +- Migrations are "squashed" between major releases (see `docs/development/migrations/`) + +## Commands + +### Local + +```bash +bundle exec rails g migration MigrationName # Generate a migration +bundle exec rails db:migrate # Run migrations +bundle exec rails db:rollback # Rollback last migration +bundle exec rails db:seed # Seed sample data +``` + +### Docker + +```bash +bin/compose exec backend bundle exec rails db:migrate # Run migrations +bin/compose exec backend bundle exec rails db:seed # Seed data +``` + +## Important Note + +**CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it) diff --git a/db/CLAUDE.md b/db/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/db/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docker/dev/AGENTS.md b/docker/dev/AGENTS.md new file mode 100644 index 00000000000..f7492804b5a --- /dev/null +++ b/docker/dev/AGENTS.md @@ -0,0 +1,61 @@ +# Docker Development + +The Docker development environment uses configurations in `docker/dev/` and the `bin/compose` wrapper script. + +## Setup + +```bash +# Initial setup (first time only) +bin/compose setup # Installs backend and frontend dependencies + +# Starting services +bin/compose start # Start backend and frontend in background +bin/compose run # Start frontend in background, backend in foreground (for debugging with pry) + +# Running tests +bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container + +# Other operations +bin/compose reset # Remove all containers and volumes (requires setup again) +bin/compose # Pass any docker-compose command directly +``` + +## Important Notes + +- **CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it) +- Most developers use a local `docker-compose.override.yml` for custom port mappings and configurations +- Copy `docker-compose.override.example.yml` to `docker-compose.override.yml` and customize as needed +- Default ports: Backend at http://localhost:3000 (or 4200 for frontend dev server) +- Services: `backend`, `frontend`, `worker`, `db`, `db-test`, `backend-test`, `cache` +- Persisted volumes: `pgdata`, `bundle`, `npm`, `tmp`, `opdata` (data survives container restarts) +- Docker build context: Uses Dockerfiles in `docker/dev/backend/` and `docker/dev/frontend/` + +## Commands Reference + +```bash +# Setup and lifecycle +bin/compose setup # Setup Docker environment (first time) +bin/compose start # Start all services in background +bin/compose run # Start frontend in background, backend in foreground +bin/compose reset # Remove all containers and volumes +bin/compose stop # Stop all services +bin/compose down # Stop and remove containers + +# Testing +bin/compose rspec spec/models/user_spec.rb # Run specific tests +bin/compose exec backend bundle exec rspec # Run tests directly in backend container + +# Development +bin/compose exec backend bundle exec rails console # Rails console +bin/compose logs backend # View backend logs +bin/compose logs -f backend # Follow backend logs +bin/compose ps # List running containers + +# Database +bin/compose exec backend bundle exec rails db:migrate # Run migrations +bin/compose exec backend bundle exec rails db:seed # Seed data + +# Direct docker-compose commands +bin/compose up -d # Start services +bin/compose restart backend # Restart backend service +``` diff --git a/docker/dev/CLAUDE.md b/docker/dev/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/docker/dev/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 00000000000..5bccd6fbcba --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,46 @@ +# Frontend + +## Directory Structure + +- `frontend/src/` - Frontend code + - `frontend/src/app/` - Legacy Angular modules/components + - `frontend/src/stimulus/` - Stimulus controllers + - `frontend/src/turbo/` - Turbo integration + +## Configuration Files + +- `frontend/eslint.config.mjs` - JavaScript/TypeScript linting +- `package.json` / `frontend/package.json` - Node.js dependencies + +## Version Requirements + +- Node: `^22.21.0` (see `package.json` engines) + +## Setup + +```bash +cd frontend && npm ci && cd .. # Install Node packages +``` + +## Code Style + +### JavaScript/TypeScript + +- **New development**: Use Hotwire (Turbo + Stimulus) with server-rendered HTML +- **Legacy code**: Follow ESLint rules +- Prefer TypeScript over JavaScript +- Use [Primer Design System](https://primer.style/product/) via ViewComponent + +## Linting + +```bash +# JavaScript/TypeScript +cd frontend && npx eslint src/ && cd .. +``` + +## Testing + +```bash +# Frontend (Jasmine/Karma) +cd frontend && npm test && cd .. +``` diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/spec/AGENTS.md b/spec/AGENTS.md new file mode 100644 index 00000000000..b652adee5bd --- /dev/null +++ b/spec/AGENTS.md @@ -0,0 +1,32 @@ +# Spec + +## Directory Structure + +- `spec/features/` - System/feature tests (Capybara) +- `spec/models/` - Model unit tests +- `spec/requests/` - API/integration tests +- `spec/services/` - Service tests + +## Running Tests + +```bash +# Backend (RSpec) - prefer specific tests over running all +bundle exec rspec spec/models/user_spec.rb # Single file +bundle exec rspec spec/models/user_spec.rb:42 # Single line +bundle exec rspec spec/features # Directory +bundle exec rake parallel:spec # Parallel execution +``` + +### Docker + +```bash +bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container +bin/compose exec backend bundle exec rspec # Run tests directly in backend container +``` + +## Debugging CI Failures + +```bash +./script/github_pr_errors | xargs bundle exec rspec # Run failed tests from CI +./script/bulk_run_rspec spec/path/to/flaky_spec.rb # Run tests multiple times +``` diff --git a/spec/CLAUDE.md b/spec/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/spec/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From d1b117ea1732ff8e614b04cf89204b6463ab29b8 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:03:58 +0200 Subject: [PATCH 232/435] Address CR comments --- AGENTS.md | 24 +++++++++++++++++------- app/AGENTS.md | 11 ----------- frontend/AGENTS.md | 18 +++++++++--------- spec/AGENTS.md | 2 +- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 27510a06d32..7ec29b0adc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,15 +38,15 @@ See [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md) for full Docker setup and com ### Key Directories -- `app/` — Rails application code (see [`app/AGENTS.md`](app/AGENTS.md)) -- `config/` — Rails configuration, routes, locales (see [`config/AGENTS.md`](config/AGENTS.md)) -- `db/` — Database migrations and seeds (see [`db/AGENTS.md`](db/AGENTS.md)) -- `docker/dev/` — Docker development environment (see [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md)) -- `frontend/` — TypeScript/Angular/Stimulus frontend (see [`frontend/AGENTS.md`](frontend/AGENTS.md)) +- `app/` — Rails application code +- `config/` — Rails configuration, routes, locales +- `db/` — Database migrations and seeds +- `docker/dev/` — Docker development environment +- `frontend/` — TypeScript/Angular/Stimulus frontend - `lib/` — Ruby libraries and extensions - `lookbook/` — ViewComponent previews () - `modules/` — OpenProject plugin modules -- `spec/` — RSpec test suite (see [`spec/AGENTS.md`](spec/AGENTS.md)) +- `spec/` — RSpec test suite ### Configuration Files @@ -61,8 +61,18 @@ See [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md) for full Docker setup and com ### Linting (Run Before Committing) ```bash +# Ruby +bundle exec rubocop # Check all files +bin/dirty-rubocop --uncommitted # Check only uncommitted changes + +# JavaScript/TypeScript +cd frontend && npx eslint src/ && cd .. + +# ERB Templates +erb_lint {files} + # Install Git Hooks (recommended) -bundle exec lefthook install # Install Git hooks (run once after cloning) +bundle exec lefthook install ``` ## Commit Messages diff --git a/app/AGENTS.md b/app/AGENTS.md index 0b420cfa791..6da76af89b3 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -29,14 +29,3 @@ ## Translations - UI strings must use translation keys (never hard-coded) - -## Linting - -```bash -# Ruby -bundle exec rubocop # Check all files -bin/dirty-rubocop --uncommitted # Check only uncommitted changes - -# ERB Templates -erb_lint {files} -``` diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 5bccd6fbcba..892d0242019 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -2,15 +2,15 @@ ## Directory Structure -- `frontend/src/` - Frontend code - - `frontend/src/app/` - Legacy Angular modules/components - - `frontend/src/stimulus/` - Stimulus controllers - - `frontend/src/turbo/` - Turbo integration +- `./src/` - Frontend code + - `./src/app/` - Legacy Angular modules/components + - `./src/stimulus/` - Stimulus controllers + - `./src/turbo/` - Turbo integration ## Configuration Files -- `frontend/eslint.config.mjs` - JavaScript/TypeScript linting -- `package.json` / `frontend/package.json` - Node.js dependencies +- `eslint.config.mjs` - JavaScript/TypeScript linting +- `../package.json` / `./frontend/package.json` - Node.js dependencies ## Version Requirements @@ -19,7 +19,7 @@ ## Setup ```bash -cd frontend && npm ci && cd .. # Install Node packages +npm ci && cd .. # Install Node packages ``` ## Code Style @@ -35,12 +35,12 @@ cd frontend && npm ci && cd .. # Install Node packages ```bash # JavaScript/TypeScript -cd frontend && npx eslint src/ && cd .. +npx eslint src/ && cd .. ``` ## Testing ```bash # Frontend (Jasmine/Karma) -cd frontend && npm test && cd .. +npm test && cd .. ``` diff --git a/spec/AGENTS.md b/spec/AGENTS.md index b652adee5bd..f5fd6ce1e62 100644 --- a/spec/AGENTS.md +++ b/spec/AGENTS.md @@ -14,7 +14,7 @@ bundle exec rspec spec/models/user_spec.rb # Single file bundle exec rspec spec/models/user_spec.rb:42 # Single line bundle exec rspec spec/features # Directory -bundle exec rake parallel:spec # Parallel execution +RAILS_ENV=test ./bin/rails parallel:spec # Parallel execution ``` ### Docker From 3f83a921b4b804e233a0943a9396cbae6dc657bb Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 12:09:00 +0100 Subject: [PATCH 233/435] Also add children groups as members --- .../groups/create_inherited_roles_service.rb | 1 + app/services/groups/update_roles_service.rb | 6 +- app/services/groups/update_service.rb | 14 +- app/services/members/create_service.rb | 22 ++- app/services/members/update_service.rb | 4 +- .../hierarchy_membership_integration_spec.rb | 185 ++++++++++++++++++ 6 files changed, 224 insertions(+), 8 deletions(-) diff --git a/app/services/groups/create_inherited_roles_service.rb b/app/services/groups/create_inherited_roles_service.rb index 0870130551e..326999807ac 100644 --- a/app/services/groups/create_inherited_roles_service.rb +++ b/app/services/groups/create_inherited_roles_service.rb @@ -85,6 +85,7 @@ module Groups FROM #{MemberRole.table_name} member_roles JOIN #{Member.table_name} members ON members.id = member_roles.member_id AND members.user_id = :group_id + WHERE member_roles.inherited_from IS NULL ), -- find members that already exist existing_members AS ( diff --git a/app/services/groups/update_roles_service.rb b/app/services/groups/update_roles_service.rb index 4297c4faea5..9a9bb269809 100644 --- a/app/services/groups/update_roles_service.rb +++ b/app/services/groups/update_roles_service.rb @@ -45,7 +45,10 @@ module Groups def modify_members_and_roles(params) member = params.fetch(:member) - user_ids = params.fetch(:user_ids) { model.self_and_descendants.flat_map(&:user_ids).uniq } + user_ids = params.fetch(:user_ids) do + group_ids = model.descendants.pluck(:id) + (model.self_and_descendants.flat_map(&:user_ids) + group_ids).uniq + end sql_query = ::OpenProject::SqlSanitization .sanitize(update_roles_cte, @@ -73,6 +76,7 @@ module Groups member_roles.id FROM #{MemberRole.table_name} member_roles WHERE member_roles.member_id = :member_id + AND member_roles.inherited_from IS NULL ), -- delete all roles assigned to users that group no longer has but keep those that the user -- has independently of the group (not inherited) or inherited from a different group diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index f5f62c636a6..2f7861026d6 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -113,13 +113,15 @@ class Groups::UpdateService < BaseServices::Update end def propagate_ancestor_memberships + group_ids = model.self_and_descendants.pluck(:id) user_ids = model.self_and_descendants.flat_map(&:user_ids).uniq - return if user_ids.empty? + principal_ids = (user_ids + group_ids).uniq + return if principal_ids.empty? model.ancestors.each do |ancestor| Groups::CreateInheritedRolesService .new(ancestor, current_user: user) - .call(user_ids:) + .call(user_ids: principal_ids) end end @@ -128,16 +130,18 @@ class Groups::UpdateService < BaseServices::Update return unless former_parent affected_users = model.self_and_descendants.flat_map(&:users).uniq - return if affected_users.empty? + affected_group_ids = model.self_and_descendants.pluck(:id) + return if affected_users.empty? && affected_group_ids.empty? former_parent.self_and_ancestors.each do |ancestor| users_not_in_ancestor = affected_users.reject { |u| ancestor.user_ids.include?(u.id) } - next if users_not_in_ancestor.empty? + principal_ids_to_clean = users_not_in_ancestor.map(&:id) + affected_group_ids + next if principal_ids_to_clean.empty? role_ids_to_clean = MemberRole .joins(:member) .where(inherited_from: ancestor.members.joins(:member_roles).select("member_roles.id")) - .where(members: { user_id: users_not_in_ancestor.map(&:id) }) + .where(members: { user_id: principal_ids_to_clean }) .pluck(:id) next if role_ids_to_clean.empty? diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 510ac2dac2a..417b16f9ff1 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -46,15 +46,35 @@ class Members::CreateService < BaseServices::Create protected + # When a Group is being added as a member to a project, an inherited Member + # may already exist (created by ancestor group membership propagation). + # In that case, find the existing member so we add direct roles to it + # rather than failing on the uniqueness constraint. + def instance(params) + principal = params[:principal] + if principal.is_a?(Group) + Member.find_or_initialize_by( + user_id: principal.id, + project_id: params[:project_id], + entity_type: params[:entity_type], + entity_id: params[:entity_id] + ) + else + super + end + end + def add_group_memberships(member) return unless member.principal.is_a?(Group) project_ids = member.project_id.nil? ? nil : [member.project_id] + group_ids = member.principal.descendants.pluck(:id) user_ids = member.principal.self_and_descendants.flat_map(&:user_ids).uniq + principal_ids = (user_ids + group_ids).uniq Groups::CreateInheritedRolesService .new(member.principal, current_user: user, contract_class: EmptyContract) - .call(user_ids: user_ids, send_notifications: false, project_ids:) + .call(user_ids: principal_ids, send_notifications: false, project_ids:) end def event_type diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index 8054e6c4ff5..ca600acd012 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -51,11 +51,13 @@ class Members::UpdateService < BaseServices::Update end def update_group_roles(member) + group_ids = member.principal.descendants.pluck(:id) user_ids = member.principal.self_and_descendants.flat_map(&:user_ids).uniq + principal_ids = (user_ids + group_ids).uniq Groups::UpdateRolesService .new(member.principal, current_user: user, contract_class: EmptyContract) - .call(member:, user_ids:, send_notifications: send_notifications?, message: notification_message) + .call(member:, user_ids: principal_ids, send_notifications: send_notifications?, message: notification_message) end def event_type diff --git a/spec/services/groups/hierarchy_membership_integration_spec.rb b/spec/services/groups/hierarchy_membership_integration_spec.rb index 16fbe701dad..94dcf215e6f 100644 --- a/spec/services/groups/hierarchy_membership_integration_spec.rb +++ b/spec/services/groups/hierarchy_membership_integration_spec.rb @@ -553,4 +553,189 @@ RSpec.describe "Group hierarchy membership propagation", type: :model do expect(new_user.memberships.find_by(project:)&.roles).to contain_exactly(root_role, mid_role) end end + + # --------------------------------------------------------------------------- + # Child group membership propagation — descendant groups themselves get + # inherited Member records, not just the users within them + # --------------------------------------------------------------------------- + + describe "child group membership propagation" do + describe "Members::CreateService" do + it "creates inherited memberships for descendant groups when a parent group is added to a project" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + mid_member = Member.find_by(principal: mid_group, project:) + leaf_member = Member.find_by(principal: leaf_group, project:) + + expect(mid_member).to be_present + expect(mid_member.roles).to contain_exactly(role) + expect(mid_member.member_roles.all? { |mr| mr.inherited_from.present? }).to be(true) + + expect(leaf_member).to be_present + expect(leaf_member.roles).to contain_exactly(role) + expect(leaf_member.member_roles.all? { |mr| mr.inherited_from.present? }).to be(true) + end + + it "does not create inherited memberships for ancestor groups" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [role.id]) + + expect(Member.find_by(principal: root_group, project:)).to be_nil + end + end + + describe "Members::UpdateService" do + it "updates inherited roles on descendant group members when the parent group's roles change" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + second_role = create(:project_role) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + group_member = Member.find_by!(principal: root_group, project:) + + Members::UpdateService + .new(user: admin, model: group_member) + .call(role_ids: [role.id, second_role.id]) + + expect(Member.find_by(principal: mid_group, project:).roles).to contain_exactly(role, second_role) + expect(Member.find_by(principal: leaf_group, project:).roles).to contain_exactly(role, second_role) + end + end + + describe "Members::DeleteService" do + it "removes inherited memberships from descendant groups when the parent group's membership is deleted" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + group_member = Member.find_by!(principal: root_group, project:) + + Members::DeleteService + .new(user: admin, model: group_member) + .call + + expect(Member.find_by(principal: mid_group, project:)).to be_nil + expect(Member.find_by(principal: leaf_group, project:)).to be_nil + end + end + + describe "parent change" do + it "propagates ancestor memberships to child groups when a parent is assigned" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user]) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: root_group.id) + + expect(Member.find_by(principal: mid_group, project:)&.roles).to contain_exactly(role) + expect(Member.find_by(principal: leaf_group, project:)&.roles).to contain_exactly(role) + end + + it "cleans up inherited child group memberships when the parent link is broken" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + # Verify child groups have memberships before breaking the link + expect(Member.find_by(principal: mid_group, project:)).to be_present + expect(Member.find_by(principal: leaf_group, project:)).to be_present + + Groups::UpdateService + .new(user: admin, model: mid_group) + .call(parent_id: nil) + + expect(Member.find_by(principal: mid_group, project:)).to be_nil + expect(Member.find_by(principal: leaf_group, project:)).to be_nil + end + end + + describe "Members::DeleteService with pre-existing child group membership" do + it "retains the child group's own membership when the parent group's membership is deleted" do + mid_role = create(:project_role) + root_role = create(:project_role) + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + # mid_group gets its own direct membership first + Members::CreateService + .new(user: admin) + .call(principal: mid_group, project_id: project.id, role_ids: [mid_role.id]) + + # Then root_group is added — this propagates root_role to mid_group, leaf_group, and all users + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [root_role.id]) + + # mid_group now has both its direct mid_role and inherited root_role + expect(Member.find_by(principal: mid_group, project:).roles).to contain_exactly(mid_role, root_role) + expect(Member.find_by(principal: leaf_group, project:)&.roles).to contain_exactly(mid_role, root_role) + + # Delete root_group's membership + root_member = Member.find_by!(principal: root_group, project:) + Members::DeleteService + .new(user: admin, model: root_member) + .call + + # mid_group keeps its own direct membership with mid_role + expect(Member.find_by(principal: mid_group, project:)&.roles).to contain_exactly(mid_role) + # leaf_group keeps the inherited mid_role from mid_group + expect(Member.find_by(principal: leaf_group, project:)&.roles).to contain_exactly(mid_role) + # Users also retain mid_role + expect(mid_user.memberships.find_by(project:)&.roles).to contain_exactly(mid_role) + expect(leaf_user.memberships.find_by(project:)&.roles).to contain_exactly(mid_role) + end + end + + describe "user removal from group" do + it "does not affect child group memberships when a user is removed from a group" do + root_group = create(:group, members: [root_user]) + mid_group = create(:group, members: [mid_user], parent: root_group) + leaf_group = create(:group, members: [leaf_user], parent: mid_group) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + + # Remove leaf_user from leaf_group + Groups::UpdateService + .new(user: admin, model: leaf_group) + .call(remove_user_ids: [leaf_user.id]) + + # Child group memberships should remain intact + expect(Member.find_by(principal: mid_group, project:)&.roles).to contain_exactly(role) + expect(Member.find_by(principal: leaf_group, project:)&.roles).to contain_exactly(role) + end + end + end end From 03ba128ec9f0f4ab25f46ff3bab27fb70717bc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Alcob=C3=A9=20Tall=C3=B3?= <88369818+marcalcobe@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:20:26 +0100 Subject: [PATCH 234/435] Update iOS version and fix links Due to the inclusion of OS specific widgets we need to update the iOS version requirement from 15 to 17. Unrelated to this some links URLs where missing and where added. --- docs/mobile-app-guide/README.md | 3 +-- docs/mobile-app-guide/faq/README.md | 4 ++-- docs/mobile-app-guide/first-steps/README.md | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/mobile-app-guide/README.md b/docs/mobile-app-guide/README.md index f68613735a9..9fe2daa50ec 100644 --- a/docs/mobile-app-guide/README.md +++ b/docs/mobile-app-guide/README.md @@ -54,7 +54,6 @@ The app is still under development, and many core features are planned for futur * **Deep-linking (including on-premises support):** Seamlessly open specific work packages, projects, or comments directly from links in emails, chats, or browser pages — whether you’re using the cloud or an on-premises instance. * **Multi-device UI:** Enjoy a consistent, responsive experience across phones, tablets and macOS, with layouts optimized for each device size and orientation. * **Real-time push notifications:** Receive updates instantly as they happen — from mentions and comments to task status changes — ensuring you never miss important activity. -* **Write internal comments:** Add internal comments directly from the app, enabling secure collaboration within your project team while keeping external communications separate. * **Meeting agendas in the app:** Access and review meeting agendas on the go to stay prepared and aligned with your team wherever you are. These upcoming features will make the OpenProject Mobile App even more connected, collaborative, and aligned with the full OpenProject experience. @@ -86,7 +85,7 @@ To access and use the **OpenProject Mobile App (Beta)**, you’ll need the follo > [!NOTE] > If you have a previous version of OpenProject you can connect your OpenProject instance by asking your administrator to enable the Built in OAuth applications flag under_ `_{BASE_URL}/admin/settings/experimental_`. * **Minimum system requirements:** - * **iOS 15** or later + * **iOS 17** or later * **Android 12** or later * **Built-in OAuth applications enabled:** Make sure that the built-in OAuth applications are **enabled in your administration settings** (`{BASE_URL}/admin/oauth/applications`). This is required for successful login from the mobile app. ![Applications setting to enable the built-in OAuth](mobile_app_oauth_authentication.png) diff --git a/docs/mobile-app-guide/faq/README.md b/docs/mobile-app-guide/faq/README.md index fe82260611f..5b29c137e63 100644 --- a/docs/mobile-app-guide/faq/README.md +++ b/docs/mobile-app-guide/faq/README.md @@ -18,8 +18,8 @@ The OpenProject Mobile App is a companion to the OpenProject desktop and web app ## What platforms are supported? -* **iOS 15 or later** -* **Android 10 or later** +* **iOS 17 or later** +* **Android 12 or later** The app requires an **active internet connection** to sync data with your OpenProject instance. diff --git a/docs/mobile-app-guide/first-steps/README.md b/docs/mobile-app-guide/first-steps/README.md index 9ac74a68640..69f4a459603 100644 --- a/docs/mobile-app-guide/first-steps/README.md +++ b/docs/mobile-app-guide/first-steps/README.md @@ -20,7 +20,7 @@ Before downloading the app, please ensure your environment meets the following p > [!NOTE] > If you have a previous version of OpenProject you can connect your OpenProject instance by asking your administrator to enable the Built in OAuth applications flag under `_{BASE_URL}/admin/settings/experimental_`. * **Minimum system requirements:** - * **iOS 15** or later + * **iOS 17** or later * **Android 12** or later * **Built-in OAuth applications enabled:** Make sure that the built-in OAuth applications are **enabled in your administration settings** (`{BASE_URL}/admin/oauth/applications`). This is required for successful login from the mobile app. ![Applications setting to enable the built-in OAuth in OpenProject](mobile_app_oauth_authentication.png) @@ -32,8 +32,8 @@ Before downloading the app, please ensure your environment meets the following p The **OpenProject Mobile App** is available for both major platforms: -* **iOS:** [App Store link](?). -* **Android:** [Google Play link](?). +* **iOS:** [App Store link](https://apps.apple.com/us/app/openproject/id6474431879). +* **Android:** [Google Play link](https://play.google.com/store/apps/details?id=org.openproject.app&hl=en). Search for **“OpenProject”** in your app store, or use the direct links above to download the app. From 7d49707c4b9ebcf6dbcd478eda0f09b6e58d87b3 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:56:43 +0200 Subject: [PATCH 235/435] Revert "Split the root Agent.md files into subdirectories." --- AGENTS.md | 184 ++++++++++++++++++++++++++++++++++++++----- app/AGENTS.md | 31 -------- app/CLAUDE.md | 1 - config/AGENTS.md | 14 ---- config/CLAUDE.md | 1 - db/AGENTS.md | 30 ------- db/CLAUDE.md | 1 - docker/dev/AGENTS.md | 61 -------------- docker/dev/CLAUDE.md | 1 - frontend/AGENTS.md | 46 ----------- frontend/CLAUDE.md | 1 - spec/AGENTS.md | 32 -------- spec/CLAUDE.md | 1 - 13 files changed, 166 insertions(+), 238 deletions(-) delete mode 100644 app/AGENTS.md delete mode 120000 app/CLAUDE.md delete mode 100644 config/AGENTS.md delete mode 120000 config/CLAUDE.md delete mode 100644 db/AGENTS.md delete mode 120000 db/CLAUDE.md delete mode 100644 docker/dev/AGENTS.md delete mode 120000 docker/dev/CLAUDE.md delete mode 100644 frontend/AGENTS.md delete mode 120000 frontend/CLAUDE.md delete mode 100644 spec/AGENTS.md delete mode 120000 spec/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 7ec29b0adc3..dac00fe3410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ - Node: `^22.21.0` (see `package.json` engines) - Bundler: Latest 2.x +OpenProject supports two development setups: **Local** and **Docker**. Choose one based on your preference. + ### Local Development Setup ```bash @@ -32,24 +34,59 @@ bin/dev # Start all services (Rails, frontend, Good Job ### Docker Development Setup -See [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md) for full Docker setup and commands. +The Docker development environment uses configurations in `docker/dev/` and the `bin/compose` wrapper script. + +```bash +# Initial setup (first time only) +bin/compose setup # Installs backend and frontend dependencies + +# Starting services +bin/compose start # Start backend and frontend in background +bin/compose run # Start frontend in background, backend in foreground (for debugging with pry) + +# Running tests +bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container + +# Other operations +bin/compose reset # Remove all containers and volumes (requires setup again) +bin/compose # Pass any docker-compose command directly +``` + +**Important Docker Notes:** +- **CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it) +- Most developers use a local `docker-compose.override.yml` for custom port mappings and configurations +- Copy `docker-compose.override.example.yml` to `docker-compose.override.yml` and customize as needed +- Default ports: Backend at http://localhost:3000 (or 4200 for frontend dev server) +- Services: `backend`, `frontend`, `worker`, `db`, `db-test`, `backend-test`, `cache` +- Persisted volumes: `pgdata`, `bundle`, `npm`, `tmp`, `opdata` (data survives container restarts) +- Docker build context: Uses Dockerfiles in `docker/dev/backend/` and `docker/dev/frontend/` ## Project Structure ### Key Directories - -- `app/` — Rails application code -- `config/` — Rails configuration, routes, locales -- `db/` — Database migrations and seeds -- `docker/dev/` — Docker development environment -- `frontend/` — TypeScript/Angular/Stimulus frontend -- `lib/` — Ruby libraries and extensions -- `lookbook/` — ViewComponent previews () -- `modules/` — OpenProject plugin modules -- `spec/` — RSpec test suite +- `app/` - Rails application code + - `app/components/` - ViewComponent-based UI components (Ruby + ERB) + - `app/contracts/` - Validation and authorization contracts + - `app/controllers/` - Rails controllers + - `app/models/` - ActiveRecord models + - `app/services/` - Service objects (business logic) + - `app/workers/` - Background job workers +- `config/` - Rails configuration, routes, locales +- `db/` - Database migrations and seeds +- `frontend/src/` - Frontend code + - `frontend/src/app/` - Legacy Angular modules/components + - `frontend/src/stimulus/` - Stimulus controllers + - `frontend/src/turbo/` - Turbo integration +- `lib/` - Ruby libraries and extensions +- `lookbook/` - ViewComponent previews (https://qa.openproject-edge.com/lookbook/) +- `modules/` - OpenProject plugin modules +- `spec/` - RSpec test suite + - `spec/features/` - System/feature tests (Capybara) + - `spec/models/` - Model unit tests + - `spec/requests/` - API/integration tests + - `spec/services/` - Service tests ### Configuration Files - - `.ruby-version` - Ruby version - `.rubocop.yml` - Ruby linting rules - `.erb_lint.yml` - ERB template linting @@ -58,6 +95,8 @@ See [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md) for full Docker setup and com - `package.json` / `frontend/package.json` - Node.js dependencies - `lefthook.yml` - Git hooks configuration +## Building and Testing + ### Linting (Run Before Committing) ```bash @@ -75,16 +114,125 @@ erb_lint {files} bundle exec lefthook install ``` -## Commit Messages +### Running Tests +```bash +# Backend (RSpec) - prefer specific tests over running all +bundle exec rspec spec/models/user_spec.rb # Single file +bundle exec rspec spec/models/user_spec.rb:42 # Single line +bundle exec rspec spec/features # Directory +bundle exec rake parallel:spec # Parallel execution + +# Frontend (Jasmine/Karma) +cd frontend && npm test && cd .. +``` + +### Debugging CI Failures +```bash +./script/github_pr_errors | xargs bundle exec rspec # Run failed tests from CI +./script/bulk_run_rspec spec/path/to/flaky_spec.rb # Run tests multiple times +``` + +## Code Style Guidelines + +### Ruby +- Follow [Ruby community style guide](https://github.com/bbatsov/ruby-style-guide) +- Use service objects for complex business logic (return `ServiceResult`) +- Use contracts for validation and authorization +- Keep controllers thin, models focused +- Document with [YARD](https://yardoc.org/) +- Write RSpec tests for all new features + +### JavaScript/TypeScript +- **New development**: Use Hotwire (Turbo + Stimulus) with server-rendered HTML +- **Legacy code**: Follow ESLint rules +- Prefer TypeScript over JavaScript +- Use [Primer Design System](https://primer.style/product/) via ViewComponent + +### Templates +- Use ERB for server-rendered views +- Use ViewComponents for reusable UI (with Lookbook previews) +- Lint with erb_lint before committing + +### Database Migrations +- Follow Rails migration conventions +- Migrations are "squashed" between major releases (see `docs/development/migrations/`) + +### Translations +- UI strings must use translation keys (never hard-coded) +- Source translations in `**/config/locales/en.yml` can be modified directly +- Other translations managed via Crowdin + +### Commit Messages - First line: < 72 characters, then blank line, then detailed description - Reference work packages when applicable - Merge strategy: "Merge pull request" (not squash), except single-commit PRs can use "Rebase and merge" +## Important Commands Reference + +### Local Development Commands + +```bash +# Setup +bin/setup # Initial Rails setup +bin/setup_dev # Full dev environment setup + +# Database +bundle exec rails g migration MigrationName # Generate a migration +bundle exec rails db:migrate # Run migrations +bundle exec rails db:rollback # Rollback last migration +bundle exec rails db:seed # Seed sample data + +# Development +bin/dev # Start all services +bundle exec rails console # Rails console +bundle exec rails routes # List routes + +# Testing +bundle exec rspec # Run RSpec tests +bundle exec rails parallel:spec # Parallel tests +cd frontend && npm test # Frontend tests + +# Linting +bundle exec rubocop # Ruby linting +cd frontend && npx eslint src/ # JS/TS linting +erb_lint {files} # ERB linting +``` + +### Docker Development Commands + +```bash +# Setup and lifecycle +bin/compose setup # Setup Docker environment (first time) +bin/compose start # Start all services in background +bin/compose run # Start frontend in background, backend in foreground +bin/compose reset # Remove all containers and volumes +bin/compose stop # Stop all services +bin/compose down # Stop and remove containers + +# Testing +bin/compose rspec spec/models/user_spec.rb # Run specific tests +bin/compose exec backend bundle exec rspec # Run tests directly in backend container + +# Development +bin/compose exec backend bundle exec rails console # Rails console +bin/compose logs backend # View backend logs +bin/compose logs -f backend # Follow backend logs +bin/compose ps # List running containers + +# Database +bin/compose exec backend bundle exec rails db:migrate # Run migrations +bin/compose exec backend bundle exec rails db:seed # Seed data + +# Direct docker-compose commands +bin/compose up -d # Start services +bin/compose restart backend # Restart backend service +``` + ## Additional Documentation -- `docs/development/` — Development documentation -- `docs/development/running-tests/` — Testing guide -- `docs/development/code-review-guidelines/` — Code review standards -- `CONTRIBUTING.md` — Contribution workflow -- `.github/copilot-instructions.md` — Extended agent instructions with troubleshooting +- `docs/development/` - Development documentation +- `docs/development/running-tests/` - Testing guide +- `docs/development/code-review-guidelines/` - Code review standards +- `CONTRIBUTING.md` - Contribution workflow +- `.github/copilot-instructions.md` - Extended agent instructions with troubleshooting diff --git a/app/AGENTS.md b/app/AGENTS.md deleted file mode 100644 index 6da76af89b3..00000000000 --- a/app/AGENTS.md +++ /dev/null @@ -1,31 +0,0 @@ -# App - -## Directory Structure - -- `app/components/` - ViewComponent-based UI components (Ruby + ERB) -- `app/contracts/` - Validation and authorization contracts -- `app/controllers/` - Rails controllers -- `app/models/` - ActiveRecord models -- `app/services/` - Service objects (business logic) -- `app/workers/` - Background job workers - -## Code Style - -### Ruby - -- Follow [Ruby community style guide](https://github.com/bbatsov/ruby-style-guide) -- Use service objects for complex business logic (return `ServiceResult`) -- Use contracts for validation and authorization -- Keep controllers thin, models focused -- Document with [YARD](https://yardoc.org/) -- Write RSpec tests for all new features - -### Templates - -- Use ERB for server-rendered views -- Use ViewComponents for reusable UI (with Lookbook previews) -- Lint with erb_lint before committing - -## Translations - -- UI strings must use translation keys (never hard-coded) diff --git a/app/CLAUDE.md b/app/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/app/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/config/AGENTS.md b/config/AGENTS.md deleted file mode 100644 index c2f55641e23..00000000000 --- a/config/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# Config - -## Translations - -- UI strings must use translation keys (never hard-coded) -- Source translations in `**/config/locales/en.yml` can be modified directly -- Other translations managed via Crowdin - -```bash -bundle exec i18n-tasks missing # Show missing translation keys -bundle exec i18n-tasks unused # Show unused translation keys -bundle exec i18n-tasks normalize # Fix/normalize translation files -bundle exec i18n-tasks check-consistent-interpolations # Check interpolation consistency -``` diff --git a/config/CLAUDE.md b/config/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/config/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/db/AGENTS.md b/db/AGENTS.md deleted file mode 100644 index a80ff4f767b..00000000000 --- a/db/AGENTS.md +++ /dev/null @@ -1,30 +0,0 @@ -# Database - -## Code Style - -### Database Migrations - -- Follow Rails migration conventions -- Migrations are "squashed" between major releases (see `docs/development/migrations/`) - -## Commands - -### Local - -```bash -bundle exec rails g migration MigrationName # Generate a migration -bundle exec rails db:migrate # Run migrations -bundle exec rails db:rollback # Rollback last migration -bundle exec rails db:seed # Seed sample data -``` - -### Docker - -```bash -bin/compose exec backend bundle exec rails db:migrate # Run migrations -bin/compose exec backend bundle exec rails db:seed # Seed data -``` - -## Important Note - -**CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it) diff --git a/db/CLAUDE.md b/db/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/db/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docker/dev/AGENTS.md b/docker/dev/AGENTS.md deleted file mode 100644 index f7492804b5a..00000000000 --- a/docker/dev/AGENTS.md +++ /dev/null @@ -1,61 +0,0 @@ -# Docker Development - -The Docker development environment uses configurations in `docker/dev/` and the `bin/compose` wrapper script. - -## Setup - -```bash -# Initial setup (first time only) -bin/compose setup # Installs backend and frontend dependencies - -# Starting services -bin/compose start # Start backend and frontend in background -bin/compose run # Start frontend in background, backend in foreground (for debugging with pry) - -# Running tests -bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container - -# Other operations -bin/compose reset # Remove all containers and volumes (requires setup again) -bin/compose # Pass any docker-compose command directly -``` - -## Important Notes - -- **CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it) -- Most developers use a local `docker-compose.override.yml` for custom port mappings and configurations -- Copy `docker-compose.override.example.yml` to `docker-compose.override.yml` and customize as needed -- Default ports: Backend at http://localhost:3000 (or 4200 for frontend dev server) -- Services: `backend`, `frontend`, `worker`, `db`, `db-test`, `backend-test`, `cache` -- Persisted volumes: `pgdata`, `bundle`, `npm`, `tmp`, `opdata` (data survives container restarts) -- Docker build context: Uses Dockerfiles in `docker/dev/backend/` and `docker/dev/frontend/` - -## Commands Reference - -```bash -# Setup and lifecycle -bin/compose setup # Setup Docker environment (first time) -bin/compose start # Start all services in background -bin/compose run # Start frontend in background, backend in foreground -bin/compose reset # Remove all containers and volumes -bin/compose stop # Stop all services -bin/compose down # Stop and remove containers - -# Testing -bin/compose rspec spec/models/user_spec.rb # Run specific tests -bin/compose exec backend bundle exec rspec # Run tests directly in backend container - -# Development -bin/compose exec backend bundle exec rails console # Rails console -bin/compose logs backend # View backend logs -bin/compose logs -f backend # Follow backend logs -bin/compose ps # List running containers - -# Database -bin/compose exec backend bundle exec rails db:migrate # Run migrations -bin/compose exec backend bundle exec rails db:seed # Seed data - -# Direct docker-compose commands -bin/compose up -d # Start services -bin/compose restart backend # Restart backend service -``` diff --git a/docker/dev/CLAUDE.md b/docker/dev/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/docker/dev/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md deleted file mode 100644 index 892d0242019..00000000000 --- a/frontend/AGENTS.md +++ /dev/null @@ -1,46 +0,0 @@ -# Frontend - -## Directory Structure - -- `./src/` - Frontend code - - `./src/app/` - Legacy Angular modules/components - - `./src/stimulus/` - Stimulus controllers - - `./src/turbo/` - Turbo integration - -## Configuration Files - -- `eslint.config.mjs` - JavaScript/TypeScript linting -- `../package.json` / `./frontend/package.json` - Node.js dependencies - -## Version Requirements - -- Node: `^22.21.0` (see `package.json` engines) - -## Setup - -```bash -npm ci && cd .. # Install Node packages -``` - -## Code Style - -### JavaScript/TypeScript - -- **New development**: Use Hotwire (Turbo + Stimulus) with server-rendered HTML -- **Legacy code**: Follow ESLint rules -- Prefer TypeScript over JavaScript -- Use [Primer Design System](https://primer.style/product/) via ViewComponent - -## Linting - -```bash -# JavaScript/TypeScript -npx eslint src/ && cd .. -``` - -## Testing - -```bash -# Frontend (Jasmine/Karma) -npm test && cd .. -``` diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/frontend/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/spec/AGENTS.md b/spec/AGENTS.md deleted file mode 100644 index f5fd6ce1e62..00000000000 --- a/spec/AGENTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Spec - -## Directory Structure - -- `spec/features/` - System/feature tests (Capybara) -- `spec/models/` - Model unit tests -- `spec/requests/` - API/integration tests -- `spec/services/` - Service tests - -## Running Tests - -```bash -# Backend (RSpec) - prefer specific tests over running all -bundle exec rspec spec/models/user_spec.rb # Single file -bundle exec rspec spec/models/user_spec.rb:42 # Single line -bundle exec rspec spec/features # Directory -RAILS_ENV=test ./bin/rails parallel:spec # Parallel execution -``` - -### Docker - -```bash -bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container -bin/compose exec backend bundle exec rspec # Run tests directly in backend container -``` - -## Debugging CI Failures - -```bash -./script/github_pr_errors | xargs bundle exec rspec # Run failed tests from CI -./script/bulk_run_rspec spec/path/to/flaky_spec.rb # Run tests multiple times -``` diff --git a/spec/CLAUDE.md b/spec/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/spec/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file From ee83264379b08ef61c791ccdfb7ae0db2777d863 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 18 Mar 2026 12:05:58 +0100 Subject: [PATCH 236/435] Continue the endless journey of adapting the tests to the new inplaceEditFields. --- .../base_field_component.rb | 8 +++ .../boolean_input_component.rb | 11 +++- .../date_input_component.rb | 11 +++- .../rich_text_area_component.rb | 5 ++ .../text_input_component.rb | 11 +++- .../dynamic/inplace-edit.controller.ts | 10 ++-- .../project_description_widget_spec.rb | 2 +- ...nplace_edit_field_dialog_component_spec.rb | 46 ++++++++++++-- .../overview_page/inputs_spec.rb | 2 - .../overview_page/shared_context.rb | 3 +- .../overview_page/sidebar_spec.rb | 4 +- .../overview_page/update_spec.rb | 60 +++++++++++-------- .../overview_page/widget_spec.rb | 2 +- .../components/common/inplace_edit_field.rb | 56 +++++++++++++---- .../form_fields/primerized/input_field.rb | 13 +++- 15 files changed, 181 insertions(+), 63 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb index fe74be532bb..6b9a09a4479 100644 --- a/app/components/open_project/common/inplace_edit_fields/base_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/base_field_component.rb @@ -32,6 +32,8 @@ module OpenProject module Common module InplaceEditFields class BaseFieldComponent < ViewComponent::Base + include Primer::AttributesHelper + attr_reader :form, :attribute, :model, :show_action_buttons def self.display_class @@ -74,6 +76,12 @@ module OpenProject @custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i) end + + def qa_field_name + if custom_field? + "custom-field-#{custom_field.id}" + end + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb index 99b2530a8a2..b4b0e896b2c 100644 --- a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb @@ -33,8 +33,12 @@ module OpenProject module InplaceEditFields class BooleanInputComponent < BaseFieldComponent def call + @system_arguments[:data] = merge_data( + @system_arguments, + **additional_arguments + ) + form.check_box name: attribute, - **additional_arguments, **@system_arguments comment_field_if_enabled(form) @@ -55,8 +59,11 @@ module OpenProject if show_action_buttons { data: { controller: "inplace-edit", - action: "click->inplace-edit#submitForm" } + action: "click->inplace-edit#submitForm keydown.esc->inplace-edit#request", + qa_field_name: } } + else + { data: { qa_field_name: } } end end end diff --git a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb index eecb7480dc5..64de48b0458 100644 --- a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb @@ -38,8 +38,12 @@ module OpenProject end def call + @system_arguments[:data] = merge_data( + @system_arguments, + **additional_arguments + ) + form.text_field name: attribute, - **additional_arguments, **@system_arguments comment_field_if_enabled(form) @@ -52,8 +56,11 @@ module OpenProject { data: { controller: "inplace-edit", inplace_edit_url_value: reset_url, - action: "keydown.esc->inplace-edit#request change->inplace-edit#submitForm" } + action: "keydown.esc->inplace-edit#request change->inplace-edit#submitForm", + qa_field_name: } } + else + { data: { qa_field_name: } } end end end diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index f694ef03442..2a3ba245e00 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -45,6 +45,11 @@ module OpenProject @system_arguments[:rich_text_options] ||= {} @system_arguments[:rich_text_options][:primerized] = true + + @system_arguments[:data] = merge_data( + @system_arguments, + data: { qa_field_name: } + ) end def call diff --git a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb index 46b1f87e0ab..7adb698b849 100644 --- a/app/components/open_project/common/inplace_edit_fields/text_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/text_input_component.rb @@ -33,9 +33,13 @@ module OpenProject module InplaceEditFields class TextInputComponent < BaseFieldComponent def call + @system_arguments[:data] = merge_data( + @system_arguments, + **additional_arguments + ) + form.text_field name: attribute, autofocus: true, - **additional_arguments, **@system_arguments comment_field_if_enabled(form) @@ -57,8 +61,11 @@ module OpenProject { data: { controller: "inplace-edit", inplace_edit_url_value: reset_url, - action: "keydown.esc->inplace-edit#request" } + action: "keydown.esc->inplace-edit#request", + qa_field_name: } } + else + { data: { qa_field_name: } } end end end diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index 76069248f63..52a4ef7ec2e 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -42,12 +42,12 @@ export default class extends Controller { declare dialogUrlValue:string; declare hasDialogUrlValue:boolean; - private boundFormDataHandler: ((e: FormDataEvent) => void) | null = null; + private boundFormDataHandler:((e:FormDataEvent) => void) | null = null; connect() { const form = this.element.closest('form'); if (form) { - this.boundFormDataHandler = (e: FormDataEvent) => this.appendStableKeySystemArguments(e); + this.boundFormDataHandler = (e:FormDataEvent) => this.appendStableKeySystemArguments(e); form.addEventListener('formdata', this.boundFormDataHandler); } } @@ -111,8 +111,8 @@ export default class extends Controller { } } - private appendStableKeySystemArguments(e: FormDataEvent): void { - const result: Record = {}; + private appendStableKeySystemArguments(e:FormDataEvent):void { + const result:Record = {}; document.querySelectorAll('[data-inplace-edit-stable-key][data-inplace-edit-system-arguments]').forEach((el) => { const key = el.dataset.inplaceEditStableKey; const raw = el.dataset.inplaceEditSystemArguments; @@ -124,7 +124,7 @@ export default class extends Controller { } } }); - e.formData.append('stable_key_system_arguments', JSON.stringify(result)); + e.formData.set('stable_key_system_arguments', JSON.stringify(result)); } private isInteractiveElement(element:HTMLElement):boolean { diff --git a/modules/overviews/spec/features/project_description_widget_spec.rb b/modules/overviews/spec/features/project_description_widget_spec.rb index 88ec9a25aeb..33224a71a23 100644 --- a/modules/overviews/spec/features/project_description_widget_spec.rb +++ b/modules/overviews/spec/features/project_description_widget_spec.rb @@ -84,7 +84,7 @@ RSpec.describe "Project description widget", :js do # Set a new description new_description = "This is a **test** project description with markdown formatting." - description_field.fill_and_submit_value_via_button(name: "project[description]", val: new_description, ckeditor: true) + description_field.fill_and_submit_value(name: "project[description]", val: new_description, ckeditor: true) tested_page.expect_and_dismiss_flash message: I18n.t("js.notice_successful_update") diff --git a/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb b/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb index 9c69f74b4fd..b9cc8612711 100644 --- a/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb +++ b/spec/components/open_project/common/inplace_edit_field_dialog_component_spec.rb @@ -54,7 +54,8 @@ RSpec.describe OpenProject::Common::InplaceEditFieldDialogComponent, type: :comp before { allow(User).to receive(:current).and_return(build_stubbed(:user)) } it "renders a dialog with the expected ID and label" do - render_inline(described_class.new(model: project, attribute: :description, system_arguments: { update_registry: })) + render_inline(described_class.new(model: project, attribute: :description, + system_arguments: { update_registry:, writable: true })) expect(rendered_content).to have_css("#inplace-edit-field-dialog--project-#{project.id}--description") expect(rendered_content).to have_text(Project.human_attribute_name(:description)) @@ -65,17 +66,50 @@ RSpec.describe OpenProject::Common::InplaceEditFieldDialogComponent, type: :comp described_class.new( model: project, attribute: :description, - system_arguments: { update_registry:, label: "My Label" } + system_arguments: { update_registry:, writable: true, label: "My Label" } ) ) expect(rendered_content).to have_text("My Label") end - it "renders Cancel and Save buttons" do - render_inline(described_class.new(model: project, attribute: :description, system_arguments: { update_registry: })) + context "when the user has write access" do + let(:allowed_attributes) { %w[description] } - expect(rendered_content).to have_button(I18n.t(:button_cancel)) - expect(rendered_content).to have_button(I18n.t(:button_save)) + it "renders the edit form in the dialog body" do + render_inline(described_class.new(model: project, attribute: :description, + system_arguments: { update_registry:, writable: true })) + + expect(rendered_content).to have_test_selector("op-inplace-edit-field--form") + end + + it "renders Cancel and Save buttons in the footer" do + render_inline(described_class.new(model: project, attribute: :description, + system_arguments: { update_registry:, writable: true })) + + expect(rendered_content).to have_button(I18n.t(:button_cancel)) + expect(rendered_content).to have_button(I18n.t(:button_save)) + end + end + + context "when the user does not have write access" do + let(:allowed_attributes) { [] } + + it "renders the display component in the dialog body instead of the edit form" do + render_inline(described_class.new(model: project, attribute: :description, + system_arguments: { update_registry:, writable: false })) + + expect(rendered_content).not_to have_test_selector("op-inplace-edit-field--form") + expect(rendered_content).to have_css(".op-inplace-edit--display-field") + end + + it "renders only a Close button in the footer" do + render_inline(described_class.new(model: project, attribute: :description, + system_arguments: { update_registry:, writable: false })) + + expect(rendered_content).to have_button(I18n.t(:button_close)) + expect(rendered_content).to have_no_button(I18n.t(:button_cancel)) + expect(rendered_content).to have_no_button(I18n.t(:button_save)) + end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb index 379604d0f8d..890b55eafb6 100644 --- a/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb @@ -49,8 +49,6 @@ RSpec.describe "Edit project custom fields on project overview page", :js do shared_examples "shows comment input only when comments are allowed by custom field" do it "shows comment input only when comments are allowed by custom field" do - dialog.expect_closed - custom_field.update!(has_comment: true) dialog.within_async_content(close_after_yield: true) do 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 7469229b58b..2e203c5b958 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 @@ -49,8 +49,9 @@ RSpec.shared_context "with seeded projects, members and project custom fields" d create(:project_role, permissions: %i[view_work_packages view_project_attributes edit_project]) end + # TODO: Remove the edit_projects permission as soon as #73225 is fixed shared_let(:edit_attributes_role) do - create(:project_role, permissions: %i[view_work_packages view_project_attributes edit_project_attributes]) + create(:project_role, permissions: %i[view_work_packages view_project_attributes edit_project_attributes edit_project]) end let!(:admin) do 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 0782270f64e..93e74f10350 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 @@ -663,7 +663,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do # Remove value that is used in a formula: field = overview_page.open_inplace_edit_field_for_custom_field(float_project_custom_field) - field.fill_and_submit_value float_project_custom_field.name, "" + field.fill_and_submit_value name: float_project_custom_field.name, val: "" overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(calculated_from_int_project_custom_field) do @@ -680,7 +680,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do # Change the value so that the calculation succeeds. field.open_field - field.fill_and_submit_value float_project_custom_field.name, "0.2" + field.fill_and_submit_value name: float_project_custom_field.name, val: "0.2" overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(calculated_from_int_project_custom_field) do diff --git a/spec/features/projects/project_custom_fields/overview_page/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/update_spec.rb index fdd7ceadf0a..7a75e098b14 100644 --- a/spec/features/projects/project_custom_fields/overview_page/update_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/update_spec.rb @@ -42,13 +42,21 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with correct updating behaviour" do def open_dialog - dialog = overview_page.open_modal_for_custom_field(custom_field) + dialog = overview_page.open_modal_for_custom_field(custom_field).dialog yield dialog dialog.submit dialog.expect_closed end + def open_field + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + + yield field + field.submit + field.expect_close + end + shared_examples "saves custom comment" do it "saves custom comment" do custom_field.update!(has_comment: true) @@ -74,7 +82,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content I18n.t("placeholders.default") end - open_dialog do + open_field do field.check end @@ -90,7 +98,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content "Yes" end - open_dialog do + open_field do field.uncheck end @@ -100,6 +108,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "does not change the value if untouched" do + custom_field.update!(has_comment: true) overview_page.visit_page overview_page.within_custom_field_container(custom_field) do @@ -128,7 +137,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content I18n.t("placeholders.default") end - open_dialog do + open_field do field.fill_in(with: update_value) end @@ -138,6 +147,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end it "does not change the value if untouched" do + custom_field.update!(has_comment: true) overview_page.visit_page overview_page.within_custom_field_container(custom_field) do @@ -160,7 +170,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content expected_initial_value end - open_dialog do + open_field do field.fill_in(with: "") end @@ -180,7 +190,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content expected_initial_calculated_value end - open_dialog do + open_field do field.fill_in(with: update_value) end @@ -196,7 +206,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content expected_initial_calculated_value end - open_dialog do |dialog| + open_field do |dialog| # don't touch the input end @@ -212,7 +222,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_content expected_initial_calculated_value end - open_dialog do + open_field do field.fill_in(with: "") end @@ -375,7 +385,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_no_text first_option end - open_dialog do + open_field do field.select_option(first_option) end @@ -391,7 +401,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_text first_option end - open_dialog do + open_field do field.expect_selected(first_option) # wait for proper initialization # don't touch the input end @@ -408,7 +418,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_text first_option end - open_dialog do + open_field do field.clear end @@ -430,7 +440,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_no_text unused_option end - open_dialog do + open_field do # Choose the unused option as the new selection field.select_option(unused_option) end @@ -488,7 +498,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do overview_page.visit_page - open_dialog do + open_field do field.select_option(group.name) end @@ -509,7 +519,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do overview_page.visit_page - open_dialog do + open_field do field.select_option(placeholder_user.name) end @@ -532,7 +542,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_no_text first_option end - open_dialog do + open_field do field.select_option(first_option) end @@ -551,7 +561,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_no_text second_option end - open_dialog do + open_field do field.select_option(first_option) field.select_option(second_option) end @@ -570,7 +580,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_text second_option end - open_dialog do + open_field do field.deselect_option(first_option) end @@ -588,7 +598,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_text second_option end - open_dialog do + open_field do field.expect_selected(first_option, second_option) # wait for proper initialization # don't touch the values end @@ -607,7 +617,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_text second_option end - open_dialog do + open_field do field.clear end @@ -627,7 +637,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_no_text second_option end - open_dialog do + open_field do field.select_option(first_option) end @@ -636,7 +646,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do expect(page).to have_no_text second_option end - open_dialog do + open_field do field.select_option(second_option) end @@ -693,7 +703,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do overview_page.visit_page - open_dialog do + open_field do field.select_option(group.name) field.select_option(another_group.name) end @@ -720,7 +730,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do overview_page.visit_page - open_dialog do + open_field do field.select_option(placeholder_user.name) field.select_option(another_placeholder_user.name) end @@ -769,7 +779,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do overview_page.visit_page - open_dialog do + open_field do field.fill_in(with: "new value") end @@ -801,7 +811,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do overview_page.visit_page - open_dialog do + open_field do field.fill_in(with: 567) end diff --git a/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb b/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb index b169d1f800e..4ffe2f52e84 100644 --- a/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/widget_spec.rb @@ -157,7 +157,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do it "can edit a project custom field from within the widget" do field = overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field) - field.fill_and_submit_value string_project_custom_field.name, "My super awesome new value" + field.fill_and_submit_value name: string_project_custom_field.name, val: "My super awesome new value" # The new value is shown in the widget diff --git a/spec/support/components/common/inplace_edit_field.rb b/spec/support/components/common/inplace_edit_field.rb index fa613d51ece..169e8a1d3a9 100644 --- a/spec/support/components/common/inplace_edit_field.rb +++ b/spec/support/components/common/inplace_edit_field.rb @@ -70,11 +70,13 @@ module Components end end - def close + def expect_close if show_in_dialog - dialog.close + dialog.expect_close else - # todo + within_field do + expect(page).not_to have_test_selector("op-inplace-edit-field--form") + end end end @@ -93,20 +95,40 @@ module Components link.click end - def fill_and_submit_value(name, val) - fill_in(name, with: val).send_keys(:return) + def fill_and_submit_value(name:, val:, ckeditor: false) + if ckeditor + find(".ck-content").base.send_keys val + else + fill_in(name, with: val) + end + + submit + end + + def submit + if show_in_dialog + dialog.submit + elsif save_button_present? + within_field { click_on "Save" } + else + # Fields that auto-submit (e.g. boolean checkboxes) may have already closed the form. + # Use `first` with minimum: 0 to return nil instead of raising when no input is present. + within_field { page.first("input, textarea", minimum: 0)&.send_keys(:return) } + end + wait_for_network_idle end - def fill_and_submit_value_via_button(name:, val:, ckeditor: false) - within_field do - if ckeditor - find(".ck-content").base.send_keys val - else - fill_in(name, with: val) - end - click_on "Save" + def close + if show_in_dialog + dialog.close + elsif cancel_button_present? + within_field { click_on "Cancel" } + else + within_field { find("input, textarea").send_keys(:escape) } end + + wait_for_network_idle end def dialog @@ -119,6 +141,14 @@ module Components private + def save_button_present? + within_field { page.has_button?("Save") } + end + + def cancel_button_present? + within_field { page.has_button?("Cancel") } + end + def expect_field_label(label_text) expect(page).to have_element :label, text: label_text end diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb index c035f16ee98..df0f41c459f 100644 --- a/spec/support/form_fields/primerized/input_field.rb +++ b/spec/support/form_fields/primerized/input_field.rb @@ -5,7 +5,18 @@ require_relative "form_field" module FormFields module Primerized class InputField < FormField - delegate :fill_in, :check, :uncheck, to: :input_element + delegate :fill_in, to: :input_element + + # Capybara's native .click on a checkbox can update the DOM property directly + # without dispatching a browser click event, so Stimulus event handlers won't fire. + # Using execute_script with element.click() fires a real browser event. + def check + page.execute_script("document.querySelector(\"#{selector}\").click()") + end + + def uncheck + page.execute_script("document.querySelector(\"#{selector}\").click()") + end def field_container page.find(selector).first(:xpath, ".//..").first(:xpath, ".//..") From cb149f351735fa92cfd9d4d7fdc71e3eb2a6142c Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 14:19:00 +0100 Subject: [PATCH 237/435] Allow filtering by group hierarchy --- .../members/user_filter_component.rb | 6 + app/models/queries/members.rb | 1 + .../members/filters/group_hierarchy_filter.rb | 80 +++++++++++++ .../filters/group_hierarchy_filter_spec.rb | 107 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 app/models/queries/members/filters/group_hierarchy_filter.rb create mode 100644 spec/models/queries/members/filters/group_hierarchy_filter_spec.rb diff --git a/app/components/members/user_filter_component.rb b/app/components/members/user_filter_component.rb index 87eee13dd37..a37f6c65759 100644 --- a/app/components/members/user_filter_component.rb +++ b/app/components/members/user_filter_component.rb @@ -113,6 +113,12 @@ module Members end end + def filter_group(query, group_id) + if group_id.present? + query.where(:group_hierarchy, "=", group_id) + end + end + protected def filter_shares(query, role_id) diff --git a/app/models/queries/members.rb b/app/models/queries/members.rb index 27e7b3ab669..dde7a967fa9 100644 --- a/app/models/queries/members.rb +++ b/app/models/queries/members.rb @@ -36,6 +36,7 @@ module Queries::Members filter Filters::StatusFilter filter Filters::BlockedFilter filter Filters::GroupFilter + filter Filters::GroupHierarchyFilter filter Filters::RoleFilter filter Filters::PrincipalFilter filter Filters::PrincipalTypeFilter diff --git a/app/models/queries/members/filters/group_hierarchy_filter.rb b/app/models/queries/members/filters/group_hierarchy_filter.rb new file mode 100644 index 00000000000..1b0c60b883e --- /dev/null +++ b/app/models/queries/members/filters/group_hierarchy_filter.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Like GroupFilter but hierarchy-aware: matches members who are users of the +# given group or any of its descendant groups, as well as the descendant +# groups themselves (which carry inherited Member records). +class Queries::Members::Filters::GroupHierarchyFilter < Queries::Members::Filters::MemberFilter + def self.key + :group_hierarchy + end + + def allowed_values + @allowed_values ||= ::Group.pluck(:id).map { |g| [g, g.to_s] } + end + + def available? + ::Group.exists? + end + + def type + :list_optional + end + + def human_name + I18n.t("query_fields.member_of_group") + end + + def joins + :principal + end + + def where + case operator + when "=" + "users.id IN (#{hierarchy_subselect})" + when "!" + "users.id NOT IN (#{hierarchy_subselect})" + when "*" + "users.id IN (#{User.within_group([]).select(:id).to_sql})" + when "!*" + "users.id NOT IN (#{User.within_group([]).select(:id).to_sql})" + end + end + + private + + def hierarchy_subselect + groups = Group.where(id: values.map(&:to_i)) + all_group_ids = groups.flat_map { |g| g.self_and_descendants.pluck(:id) }.uniq + user_ids = User.in_group(all_group_ids).pluck(:id) + (user_ids + all_group_ids).uniq.join(",").presence || "NULL" + end +end diff --git a/spec/models/queries/members/filters/group_hierarchy_filter_spec.rb b/spec/models/queries/members/filters/group_hierarchy_filter_spec.rb new file mode 100644 index 00000000000..862ad8ba73d --- /dev/null +++ b/spec/models/queries/members/filters/group_hierarchy_filter_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Queries::Members::Filters::GroupHierarchyFilter do + include_context "filter tests" + + let(:project) { create(:project) } + let(:admin) { create(:admin) } + let(:role) { create(:project_role) } + + let(:root_user) { create(:user) } + let(:mid_user) { create(:user) } + let(:leaf_user) { create(:user) } + let(:unrelated_user) { create(:user) } + + let!(:root_group) { create(:group, members: [root_user]) } + let!(:mid_group) { create(:group, members: [mid_user], parent: root_group) } + let!(:leaf_group) { create(:group, members: [leaf_user], parent: mid_group) } + let!(:other_group) { create(:group, members: [unrelated_user]) } + + before do + allow(Notifications::GroupMemberAlteredJob).to receive(:perform_later) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + end + + it "has key :group_hierarchy" do + expect(described_class.key).to eq(:group_hierarchy) + end + + describe "#allowed_values" do + it "lists all group IDs" do + expect(instance.allowed_values.map(&:first)) + .to include(root_group.id, mid_group.id, leaf_group.id, other_group.id) + end + end + + describe '#where with operator "="' do + let(:operator) { "=" } + let(:values) { [root_group.id.to_s] } + + it "returns members for users in the group and its descendants, plus the descendant groups" do + members = Member.joins(:principal).where(project:).where(instance.where) + + principal_ids = members.pluck(:user_id) + + # Users from root, mid, and leaf groups + expect(principal_ids).to include(root_user.id, mid_user.id, leaf_user.id) + # Descendant groups themselves + expect(principal_ids).to include(mid_group.id, leaf_group.id) + # The root group itself (it's in the tree) + expect(principal_ids).to include(root_group.id) + # Unrelated user is excluded + expect(principal_ids).not_to include(unrelated_user.id) + end + end + + describe '#where with operator "!"' do + let(:operator) { "!" } + let(:values) { [root_group.id.to_s] } + + it "excludes members for users in the group hierarchy and the descendant groups" do + # Add unrelated_user as a member so there's something to match + Members::CreateService + .new(user: admin) + .call(principal: other_group, project_id: project.id, role_ids: [role.id]) + + members = Member.joins(:principal).where(project:).where(instance.where) + principal_ids = members.pluck(:user_id) + + expect(principal_ids).to include(unrelated_user.id, other_group.id) + expect(principal_ids).not_to include(root_user.id, mid_user.id, leaf_user.id) + expect(principal_ids).not_to include(mid_group.id, leaf_group.id) + end + end +end From df4cb2421745f98b1afb18beeabe9b27325e6825 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Wed, 18 Mar 2026 13:11:47 +0100 Subject: [PATCH 238/435] [#72917] Save the previous value when a project identifier changes https://community.openproject.org/work_packages/72917 This leverages friendly_id's history module: https://github.com/norman/friendly_id/tree/master https://norman.github.io/friendly_id/file.Guide.html#History__Avoiding_404_s_When_Slugs_Change We decided on keeping friendly_id because it was already used and offers exactly the functionality we need. Implementing redirects from routes that use an old identifier to the current one will be a separate commit (see https://community.openproject.org/wp/72918) --- app/models/project.rb | 30 ++++- ...20260311000000_create_friendly_id_slugs.rb | 48 ++++++++ ...2121938_initialize_historic_identifiers.rb | 51 +++++++++ .../initialize_historic_identifiers_spec.rb | 103 ++++++++++++++++++ spec/models/project_spec.rb | 63 +++++++++++ 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260311000000_create_friendly_id_slugs.rb create mode 100644 db/migrate/20260312121938_initialize_historic_identifiers.rb create mode 100644 spec/migrations/initialize_historic_identifiers_spec.rb diff --git a/app/models/project.rb b/app/models/project.rb index 5a9479b5c6b..db2fcaf947b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -212,9 +212,21 @@ class Project < ApplicationRecord format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ }, if: ->(p) { p.identifier_changed? && p.identifier.present? } + # Complements the uniqueness validation above: once an identifier has been used by a + # project, it remains reserved for that project even after the project moves to a new + # identifier. This prevents another project from claiming a "retired" identifier. + validate :identifier_not_historically_reserved, if: ->(p) { p.identifier_changed? } + validates_associated :repository, :wiki - friendly_id :identifier, use: :finders + friendly_id :identifier, use: %i[finders history], slug_column: :identifier + + # FriendlyId::Slugged adds after_validation :unset_slug_if_invalid, which reverts the + # slug column to its previous value when validation fails. With slug_column: :identifier, + # this would reset a manually-set identifier back to nil on new records. Since the + # identifier is managed by acts_as_url and user input (not FriendlyId's slug generator), + # we disable this behaviour entirely. + def unset_slug_if_invalid; end scopes :activated_in_storage, :allowed_to, @@ -356,4 +368,20 @@ class Project < ApplicationRecord OpenProject::Events::MODULE_DISABLED, disabled_module: ) end + + private + + # Checks friendly_id_slugs for any project that previously used this identifier and + # has since changed it. It allows to switch back to an identifier the project itself + # has used before. + def identifier_not_historically_reserved + return if errors.any? { |error| error.attribute == :identifier && error.type == :taken } + + already_existing = FriendlyId::Slug + .where(slug: identifier, sluggable_type: self.class.to_s) + .where.not(sluggable_id: id) + .exists? + + errors.add(:identifier, :taken) if already_existing + end end diff --git a/db/migrate/20260311000000_create_friendly_id_slugs.rb b/db/migrate/20260311000000_create_friendly_id_slugs.rb new file mode 100644 index 00000000000..3648d095567 --- /dev/null +++ b/db/migrate/20260311000000_create_friendly_id_slugs.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CreateFriendlyIdSlugs < ActiveRecord::Migration[8.1] + def change + create_table :friendly_id_slugs do |t| + t.string :slug, null: false + t.bigint :sluggable_id, null: false + t.string :sluggable_type, limit: 50 + t.string :scope + t.datetime :created_at + end + + add_index :friendly_id_slugs, %i[sluggable_type sluggable_id] + add_index :friendly_id_slugs, %i[slug sluggable_type], + length: { slug: 140, sluggable_type: 50 } + add_index :friendly_id_slugs, %i[slug sluggable_type scope], + length: { slug: 70, sluggable_type: 50, scope: 70 }, + unique: true + end +end diff --git a/db/migrate/20260312121938_initialize_historic_identifiers.rb b/db/migrate/20260312121938_initialize_historic_identifiers.rb new file mode 100644 index 00000000000..0b44654dbda --- /dev/null +++ b/db/migrate/20260312121938_initialize_historic_identifiers.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class InitializeHistoricIdentifiers < ActiveRecord::Migration[8.1] + def up + execute <<~SQL.squish + INSERT INTO friendly_id_slugs (slug, sluggable_id, sluggable_type, scope, created_at) + SELECT p.identifier, p.id, 'Project', NULL, NOW() + FROM projects p + WHERE p.identifier IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM friendly_id_slugs fis + WHERE fis.slug = p.identifier + AND fis.sluggable_id = p.id + AND fis.sluggable_type = 'Project' + AND fis.scope IS NULL + ) + SQL + end + + def down + # nothing to do / not possible + end +end diff --git a/spec/migrations/initialize_historic_identifiers_spec.rb b/spec/migrations/initialize_historic_identifiers_spec.rb new file mode 100644 index 00000000000..577dc036b1a --- /dev/null +++ b/spec/migrations/initialize_historic_identifiers_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require Rails.root.join("db/migrate/20260312121938_initialize_historic_identifiers.rb") + +RSpec.describe InitializeHistoricIdentifiers, type: :model do + subject(:execute_migration) { ActiveRecord::Migration.suppress_messages { described_class.new.up } } + + let!(:project1) { create(:project, identifier: "project-one") } + let!(:project2) { create(:project, identifier: "project-two") } + let!(:project3) { create(:project, identifier: "project-three") } + + before do + FriendlyId::Slug.delete_all + end + + it "succeeds" do + expect { execute_migration }.not_to raise_error + end + + it "creates friendly_id_slugs entries for all projects" do + expect { execute_migration }.to change(FriendlyId::Slug, :count).by(3) + end + + it "creates entries with correct attributes" do + execute_migration + + slug1 = FriendlyId::Slug.find_by(sluggable_id: project1.id, sluggable_type: "Project") + expect(slug1).to have_attributes( + slug: "project-one", + sluggable_id: project1.id, + sluggable_type: "Project", + scope: nil + ) + + slug2 = FriendlyId::Slug.find_by(sluggable_id: project2.id, sluggable_type: "Project") + expect(slug2).to have_attributes( + slug: "project-two", + sluggable_id: project2.id, + sluggable_type: "Project", + scope: nil + ) + + slug3 = FriendlyId::Slug.find_by(sluggable_id: project3.id, sluggable_type: "Project") + expect(slug3).to have_attributes( + slug: "project-three", + sluggable_id: project3.id, + sluggable_type: "Project", + scope: nil + ) + end + + it "sets created_at timestamp" do + execute_migration + + slug = FriendlyId::Slug.find_by(sluggable_id: project1.id, sluggable_type: "Project") + expect(slug.created_at).to be_present + expect(slug.created_at).to be_within(5.minutes).of(Time.zone.now) + end + + context "when friendly_id_slugs entries already exist" do + before do + FriendlyId::Slug.create!( + slug: "existing-slug", + sluggable_id: project1.id, + sluggable_type: "Project" + ) + end + + it "does not delete / overwrite existing values" do + expect { execute_migration }.to change(FriendlyId::Slug, :count).by(3) + expect(FriendlyId::Slug.find_by(slug: "existing-slug")).to be_present + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 322c4d7cc1d..6a3b91440b0 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -585,6 +585,31 @@ RSpec.describe Project do end end + it "is not allowed to clash with another project" do + create(:project, identifier: "existing") + + project = build(:project, identifier: "existing") + expect(project).not_to be_valid + expect(project.errors[:identifier]).to include("has already been taken.") + end + + it "is not allowed to clash with a former identifier of another project" do + other_project = create(:project, identifier: "former-id") + other_project.update!(identifier: "new-id") + + project = build(:project, identifier: "former-id") + expect(project).not_to be_valid + expect(project.errors[:identifier]).to include("has already been taken.") + end + + it "is allowed to be the same as its own former identifier" do + project = create(:project, identifier: "old-id") + project.update!(identifier: "new-id") + + project.identifier = "old-id" + expect(project).to be_valid + end + # The acts_as_url plugin defines validation callbacks on :create and it is not automatically # called when calling a custom context. However we need the acts_as_url callback to set the # identifier when the validations are called with the :saving_custom_fields context. @@ -609,6 +634,44 @@ RSpec.describe Project do end end end + + context "with history" do + let!(:project) { create(:project, identifier: "sc") } + + it "records the old identifier in friendly_id_slugs when identifier changes" do + project.update!(identifier: "scp") + expect(FriendlyId::Slug.where(sluggable: project).pluck(:slug)).to include("sc") + end + + it "can still find the project via its old identifier" do + project.update!(identifier: "scp") + expect(described_class.friendly.find("sc")).to eq(project) + end + + it "returns the project with its current identifier when found via old identifier" do + project.update!(identifier: "scp") + found = described_class.friendly.find("sc") + expect(found.identifier).to eq("scp") + end + + it "locks old identifier to the original project (not reusable by others)" do + project.update!(identifier: "scp") + slug = FriendlyId::Slug.find_by(slug: "sc") + expect(slug.sluggable_id).to eq(project.id) + end + + it "allows the project to revert to a previously used identifier" do + project.update!(identifier: "scp") + expect { project.update!(identifier: "sc") }.not_to raise_error + expect(project.identifier).to eq("sc") + end + + it "is valid when reverting to own historical identifier" do + project.update!(identifier: "scp") + project.identifier = "sc" + expect(project).to be_valid + end + end end describe "#allowed_parent_workspace_types" do From 07d26ec5a09361e3713ea24d371d4ebb2296602d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 14:53:48 +0100 Subject: [PATCH 239/435] Extract helper to fix rubocop --- app/services/members/create_service.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 417b16f9ff1..c011b02c2a8 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -67,16 +67,22 @@ class Members::CreateService < BaseServices::Create def add_group_memberships(member) return unless member.principal.is_a?(Group) + group = member.principal project_ids = member.project_id.nil? ? nil : [member.project_id] - group_ids = member.principal.descendants.pluck(:id) - user_ids = member.principal.self_and_descendants.flat_map(&:user_ids).uniq - principal_ids = (user_ids + group_ids).uniq + principal_ids = inheritable_principal_ids(group) Groups::CreateInheritedRolesService - .new(member.principal, current_user: user, contract_class: EmptyContract) + .new(group, current_user: user, contract_class: EmptyContract) .call(user_ids: principal_ids, send_notifications: false, project_ids:) end + def inheritable_principal_ids(group) + group_ids = group.descendants.pluck(:id) + user_ids = group.self_and_descendants.flat_map(&:user_ids).uniq + + (user_ids + group_ids).uniq + end + def event_type OpenProject::Events::MEMBER_CREATED end From c9d6a1df40bb00b48d2dc6891ffb54350828d221 Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad <62008897+bsatarnejad@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:26:56 +0100 Subject: [PATCH 240/435] [67643] Wiki menu visible when using the browser's print function/print dialog (#22380) * Set display of action list to none * Hide action list which are in an overlay --- frontend/src/global_styles/layout/_print.sass | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/global_styles/layout/_print.sass b/frontend/src/global_styles/layout/_print.sass index 530c141f83b..41d879e4765 100644 --- a/frontend/src/global_styles/layout/_print.sass +++ b/frontend/src/global_styles/layout/_print.sass @@ -10,7 +10,8 @@ .other-formats, .toolbar-items, .ui-helper-hidden-accessible, - #wiki_add_attachment + #wiki_add_attachment, + .Overlay action-list display: none !important .op-toast:not(.show-when-print) From 8e7bca3b1424c34b401141cf67d604607a3fd2c3 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 18 Mar 2026 17:56:03 +0300 Subject: [PATCH 241/435] fix(hocuspocus): include useful information in token validation errors (#22403) Port of https://github.com/opf/op-blocknote-hocuspocus/pull/55 Co-authored-by: Markus Kahl --- .../src/services/tokenValidationService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/op-blocknote-hocuspocus/src/services/tokenValidationService.ts b/extensions/op-blocknote-hocuspocus/src/services/tokenValidationService.ts index d9f01eb90b8..02ccf099c74 100644 --- a/extensions/op-blocknote-hocuspocus/src/services/tokenValidationService.ts +++ b/extensions/op-blocknote-hocuspocus/src/services/tokenValidationService.ts @@ -25,11 +25,11 @@ export async function decryptAndValidateToken( } = decryptToken(encryptedToken); if (requestOrigin && !tokenResourceUrl?.startsWith(requestOrigin)) { - throw new Error('Unauthorized: Token origin does not match request origin.'); + throw new Error(`Unauthorized: Token origin does not match request origin. Expected ${tokenResourceUrl} to start with ${requestOrigin}.`); } if (tokenResourceUrl !== resourceUrl) { - throw new Error('Unauthorized: Token resource URL does not match document.'); + throw new Error(`Unauthorized: Token resource URL does not match document. Expected ${tokenResourceUrl}, got ${resourceUrl}.`); } const response = await fetchResource(resourceUrl, oauth_token); From 64c4329f53f1240202901faf23778cc5e6ffdfae Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Wed, 18 Mar 2026 12:50:49 +0200 Subject: [PATCH 242/435] bump version of p-blocknote-extensions to 0.0.23 --- frontend/package-lock.json | 12 ++++++------ frontend/package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2978a88ee46..c2c70502719 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -108,7 +108,7 @@ "ng2-dragula": "^6.0.0", "ngx-cookie-service": "^21.1.0", "observable-array": "0.0.4", - "op-blocknote-extensions": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.22/op-blocknote-extensions-0.0.22.tgz", + "op-blocknote-extensions": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.23/op-blocknote-extensions-0.0.23.tgz", "openapi-explorer": "^2.4.788", "pako": "^2.0.3", "qr-creator": "^1.0.0", @@ -20149,9 +20149,9 @@ } }, "node_modules/op-blocknote-extensions": { - "version": "0.0.22", - "resolved": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.22/op-blocknote-extensions-0.0.22.tgz", - "integrity": "sha512-EL8/u9ZvRjWjsbOu0ot/+VNKYXnr8bkEHmUiUpyK4FUYfumwc6w7Se0Ob78BSitQgRiP3eEfg0E8pp1kS0eOQQ==", + "version": "0.0.23", + "resolved": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.23/op-blocknote-extensions-0.0.23.tgz", + "integrity": "sha512-QMGmGYt4KXooWRuYN8bVV+6oJVoeMmXBrQ8qlPaVeIzqS4fx7aWHQ6WU2vLuQAXVOF7SQweRTPnRKfpLG6O4eg==", "dependencies": { "@blocknote/core": "^0.44.2", "@blocknote/mantine": "^0.44.2", @@ -38990,8 +38990,8 @@ } }, "op-blocknote-extensions": { - "version": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.22/op-blocknote-extensions-0.0.22.tgz", - "integrity": "sha512-EL8/u9ZvRjWjsbOu0ot/+VNKYXnr8bkEHmUiUpyK4FUYfumwc6w7Se0Ob78BSitQgRiP3eEfg0E8pp1kS0eOQQ==", + "version": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.23/op-blocknote-extensions-0.0.23.tgz", + "integrity": "sha512-QMGmGYt4KXooWRuYN8bVV+6oJVoeMmXBrQ8qlPaVeIzqS4fx7aWHQ6WU2vLuQAXVOF7SQweRTPnRKfpLG6O4eg==", "requires": { "@blocknote/core": "^0.44.2", "@blocknote/mantine": "^0.44.2", diff --git a/frontend/package.json b/frontend/package.json index 586bb3e5919..038f3969cca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -163,7 +163,7 @@ "ng2-dragula": "^6.0.0", "ngx-cookie-service": "^21.1.0", "observable-array": "0.0.4", - "op-blocknote-extensions": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.22/op-blocknote-extensions-0.0.22.tgz", + "op-blocknote-extensions": "https://github.com/opf/op-blocknote-extensions/releases/download/v0.0.23/op-blocknote-extensions-0.0.23.tgz", "openapi-explorer": "^2.4.788", "pako": "^2.0.3", "qr-creator": "^1.0.0", From 359cd1c2ae4dfb6fe3a1724a5e65d7bbc691fdb4 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Wed, 18 Mar 2026 16:58:36 +0100 Subject: [PATCH 243/435] [#72917] Squash migrations for readability --- ...20260311000000_create_friendly_id_slugs.rb | 48 ------------------- ...2121938_initialize_historic_identifiers.rb | 29 +++++++++-- .../initialize_historic_identifiers_spec.rb | 24 +--------- 3 files changed, 25 insertions(+), 76 deletions(-) delete mode 100644 db/migrate/20260311000000_create_friendly_id_slugs.rb diff --git a/db/migrate/20260311000000_create_friendly_id_slugs.rb b/db/migrate/20260311000000_create_friendly_id_slugs.rb deleted file mode 100644 index 3648d095567..00000000000 --- a/db/migrate/20260311000000_create_friendly_id_slugs.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -class CreateFriendlyIdSlugs < ActiveRecord::Migration[8.1] - def change - create_table :friendly_id_slugs do |t| - t.string :slug, null: false - t.bigint :sluggable_id, null: false - t.string :sluggable_type, limit: 50 - t.string :scope - t.datetime :created_at - end - - add_index :friendly_id_slugs, %i[sluggable_type sluggable_id] - add_index :friendly_id_slugs, %i[slug sluggable_type], - length: { slug: 140, sluggable_type: 50 } - add_index :friendly_id_slugs, %i[slug sluggable_type scope], - length: { slug: 70, sluggable_type: 50, scope: 70 }, - unique: true - end -end diff --git a/db/migrate/20260312121938_initialize_historic_identifiers.rb b/db/migrate/20260312121938_initialize_historic_identifiers.rb index 0b44654dbda..43b7f2d4b60 100644 --- a/db/migrate/20260312121938_initialize_historic_identifiers.rb +++ b/db/migrate/20260312121938_initialize_historic_identifiers.rb @@ -29,7 +29,30 @@ #++ class InitializeHistoricIdentifiers < ActiveRecord::Migration[8.1] - def up + def change + create_table :friendly_id_slugs do |t| + t.string :slug, null: false + t.bigint :sluggable_id, null: false + t.string :sluggable_type, limit: 50 + t.string :scope + t.datetime :created_at + end + + add_index :friendly_id_slugs, %i[sluggable_type sluggable_id] + add_index :friendly_id_slugs, %i[slug sluggable_type], + length: { slug: 140, sluggable_type: 50 } + add_index :friendly_id_slugs, %i[slug sluggable_type scope], + length: { slug: 70, sluggable_type: 50, scope: 70 }, + unique: true + + reversible do |dir| + dir.up do + initialize_friendly_id_slugs + end + end + end + + def initialize_friendly_id_slugs execute <<~SQL.squish INSERT INTO friendly_id_slugs (slug, sluggable_id, sluggable_type, scope, created_at) SELECT p.identifier, p.id, 'Project', NULL, NOW() @@ -44,8 +67,4 @@ class InitializeHistoricIdentifiers < ActiveRecord::Migration[8.1] ) SQL end - - def down - # nothing to do / not possible - end end diff --git a/spec/migrations/initialize_historic_identifiers_spec.rb b/spec/migrations/initialize_historic_identifiers_spec.rb index 577dc036b1a..293c3ee03dc 100644 --- a/spec/migrations/initialize_historic_identifiers_spec.rb +++ b/spec/migrations/initialize_historic_identifiers_spec.rb @@ -38,20 +38,13 @@ RSpec.describe InitializeHistoricIdentifiers, type: :model do let!(:project2) { create(:project, identifier: "project-two") } let!(:project3) { create(:project, identifier: "project-three") } - before do - FriendlyId::Slug.delete_all - end - it "succeeds" do expect { execute_migration }.not_to raise_error end - it "creates friendly_id_slugs entries for all projects" do - expect { execute_migration }.to change(FriendlyId::Slug, :count).by(3) - end - it "creates entries with correct attributes" do execute_migration + expect(FriendlyId::Slug.count).to be(3) slug1 = FriendlyId::Slug.find_by(sluggable_id: project1.id, sluggable_type: "Project") expect(slug1).to have_attributes( @@ -85,19 +78,4 @@ RSpec.describe InitializeHistoricIdentifiers, type: :model do expect(slug.created_at).to be_present expect(slug.created_at).to be_within(5.minutes).of(Time.zone.now) end - - context "when friendly_id_slugs entries already exist" do - before do - FriendlyId::Slug.create!( - slug: "existing-slug", - sluggable_id: project1.id, - sluggable_type: "Project" - ) - end - - it "does not delete / overwrite existing values" do - expect { execute_migration }.to change(FriendlyId::Slug, :count).by(3) - expect(FriendlyId::Slug.find_by(slug: "existing-slug")).to be_present - end - end end From 3c7f3153f0e999286712014a87fe6614d4061c2a Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 17:52:15 +0100 Subject: [PATCH 244/435] bump aws-partitions --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 48a73a9c7d0..2e76102cf18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -347,7 +347,7 @@ GEM awesome_nested_set (3.9.0) activerecord (>= 4.0.0, < 8.2) aws-eventstream (1.4.0) - aws-partitions (1.1221.0) + aws-partitions (1.1227.0) aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -1824,7 +1824,7 @@ CHECKSUMS auto_strip_attributes (2.6.0) sha256=a7e2e0cf744de2bcd947fd68014220702bcc88c81274c1cd9ce6f7316aae39b0 awesome_nested_set (3.9.0) sha256=3ce99e816550f97f4de118e621630070aacf24928b920fe4a68846578a8daaed aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1221.0) sha256=f09304480191f5ff03f8994705067779bc8fe5b4731183ce45f092afb706e8eb + aws-partitions (1.1227.0) sha256=122dd20fe108cb38d38cccbc1f2592408bc1b30ca6e0d05797a7af2501567e29 aws-sdk-core (3.242.0) sha256=c17b3003acc78d80c1a8437b285a1cfc5e4d7749ce7821cf3071e847535a29a0 aws-sdk-kms (1.122.0) sha256=47ce3f51b26bd7d76f1270cfdfca17b40073ecd3219c8c9400788712abfb4eb8 aws-sdk-s3 (1.214.0) sha256=923135327634c873ecedbd8f396ea9939874d524b14fa65eced766660c8dd62e From c586321affff2b061a6996d7d56bc5e08191680b Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 17:52:26 +0100 Subject: [PATCH 245/435] bump aws-sdk-core --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2e76102cf18..9b1ce58db16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -348,7 +348,7 @@ GEM activerecord (>= 4.0.0, < 8.2) aws-eventstream (1.4.0) aws-partitions (1.1227.0) - aws-sdk-core (3.242.0) + aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -1825,7 +1825,7 @@ CHECKSUMS awesome_nested_set (3.9.0) sha256=3ce99e816550f97f4de118e621630070aacf24928b920fe4a68846578a8daaed aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b aws-partitions (1.1227.0) sha256=122dd20fe108cb38d38cccbc1f2592408bc1b30ca6e0d05797a7af2501567e29 - aws-sdk-core (3.242.0) sha256=c17b3003acc78d80c1a8437b285a1cfc5e4d7749ce7821cf3071e847535a29a0 + aws-sdk-core (3.243.0) sha256=a014eef785124b71d28325783fa422a1512f8421ec9b6e3931c8b0ca3fbb0f1c aws-sdk-kms (1.122.0) sha256=47ce3f51b26bd7d76f1270cfdfca17b40073ecd3219c8c9400788712abfb4eb8 aws-sdk-s3 (1.214.0) sha256=923135327634c873ecedbd8f396ea9939874d524b14fa65eced766660c8dd62e aws-sdk-sns (1.112.0) sha256=aff1b1b5bbcb4229599221c558a41790c1cd1a1fed47ac3d27d27512ad24b254 From ec0624e645d99439f9d22d26b71ce1ad41372d50 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 17:52:37 +0100 Subject: [PATCH 246/435] bump aws-sdk-s3 --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9b1ce58db16..da0d08f4ab1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -359,8 +359,8 @@ GEM aws-sdk-kms (1.122.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.214.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-s3 (1.216.0) + aws-sdk-core (~> 3, >= 3.243.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-sns (1.112.0) @@ -1827,7 +1827,7 @@ CHECKSUMS aws-partitions (1.1227.0) sha256=122dd20fe108cb38d38cccbc1f2592408bc1b30ca6e0d05797a7af2501567e29 aws-sdk-core (3.243.0) sha256=a014eef785124b71d28325783fa422a1512f8421ec9b6e3931c8b0ca3fbb0f1c aws-sdk-kms (1.122.0) sha256=47ce3f51b26bd7d76f1270cfdfca17b40073ecd3219c8c9400788712abfb4eb8 - aws-sdk-s3 (1.214.0) sha256=923135327634c873ecedbd8f396ea9939874d524b14fa65eced766660c8dd62e + aws-sdk-s3 (1.216.0) sha256=a3bf6191e6f7a3dfb04b7cc73409f059394be559e4aff92d2a764341e4d90af4 aws-sdk-sns (1.112.0) sha256=aff1b1b5bbcb4229599221c558a41790c1cd1a1fed47ac3d27d27512ad24b254 aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 axe-core-api (4.11.1) sha256=a6460506449a692030620a0574fee7afa6cd38cfbbf6620d20bf4d53d33a80cc From aa2ccd41575701e9088061f267fa8725924eee12 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 17:53:55 +0100 Subject: [PATCH 247/435] bump loofah --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index da0d08f4ab1..ae5b7c6cdde 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -795,7 +795,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lookbook (2.3.14) @@ -2004,7 +2004,7 @@ CHECKSUMS lobby_boy (0.1.3) sha256=9460bb3c052aef158eb3f137b8f7679ca756b8e2983d140dbdc0caa85c018172 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 lograge (0.14.0) sha256=42371a75823775f166f727639f5ddce73dd149452a55fc94b90c303213dc9ae1 - loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 lookbook (2.3.14) sha256=c11a693bde9915b553c4463440ad5e750829f90bff08abdb6b8610373864cd7c mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 From 66bada4ee9ebb968d17ce0c110ec3da4588e4efc Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 17:54:20 +0100 Subject: [PATCH 248/435] bump mime-types-data --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ae5b7c6cdde..1c2b79990bd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -829,7 +829,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0303) + mime-types-data (3.2026.0317) mini_magick (5.3.1) logger mini_mime (1.1.5) @@ -2016,7 +2016,7 @@ CHECKSUMS meta-tags (2.22.3) sha256=41ead5437140869717cbdd659cc6f1caa3e498b3e74b03ed63503b5b38ed504f method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 - mime-types-data (3.2026.0303) sha256=164af1de5824c5195d4b503b0a62062383b65c08671c792412450cd22d3bc224 + mime-types-data (3.2026.0317) sha256=77f078a4d8631d52b842ba77099734b06eddb7ad339d792e746d2272b67e511b mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d From 3fde0498abc28148243bed02a5e6efdd24773c18 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:10:46 +0100 Subject: [PATCH 249/435] bump opentelemetry-instrumentation-all --- Gemfile | 2 +- Gemfile.lock | 109 ++++++++++++++++++++++++++------------------------- 2 files changed, 56 insertions(+), 55 deletions(-) diff --git a/Gemfile b/Gemfile index 78d1a139ecd..7021aacbc47 100644 --- a/Gemfile +++ b/Gemfile @@ -237,7 +237,7 @@ gem "yabeda-rails" # opentelemetry gem "opentelemetry-exporter-otlp", "~> 0.31.0", require: false -gem "opentelemetry-instrumentation-all", "~> 0.90.0", require: false +gem "opentelemetry-instrumentation-all", "~> 0.91.0", require: false gem "opentelemetry-sdk", "~> 1.10", require: false gem "view_component", "~> 4.5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 1c2b79990bd..d37717711df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -910,7 +910,8 @@ GEM openssl (4.0.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.7.0) + opentelemetry-api (1.8.0) + logger opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) opentelemetry-exporter-otlp (0.31.1) @@ -920,7 +921,7 @@ GEM opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.10) opentelemetry-semantic_conventions - opentelemetry-helpers-mysql (0.4.0) + opentelemetry-helpers-mysql (0.5.0) opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-helpers-sql (0.3.0) @@ -930,7 +931,7 @@ GEM opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.6.1) opentelemetry-instrumentation-active_support (~> 0.10) - opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-action_pack (0.16.0) opentelemetry-instrumentation-rack (~> 0.29) opentelemetry-instrumentation-action_view (0.11.2) opentelemetry-instrumentation-active_support (~> 0.10) @@ -944,45 +945,45 @@ GEM opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_support (0.10.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-all (0.90.1) + opentelemetry-instrumentation-all (0.91.0) opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) - opentelemetry-instrumentation-anthropic (~> 0.3.0) + opentelemetry-instrumentation-anthropic (~> 0.4.0) opentelemetry-instrumentation-aws_lambda (~> 0.6.0) opentelemetry-instrumentation-aws_sdk (~> 0.11.0) opentelemetry-instrumentation-bunny (~> 0.24.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) - opentelemetry-instrumentation-dalli (~> 0.29.0) + opentelemetry-instrumentation-dalli (~> 0.29.2) opentelemetry-instrumentation-delayed_job (~> 0.25.1) - opentelemetry-instrumentation-ethon (~> 0.27.0) - opentelemetry-instrumentation-excon (~> 0.27.0) - opentelemetry-instrumentation-faraday (~> 0.31.0) - opentelemetry-instrumentation-grape (~> 0.5.0) - opentelemetry-instrumentation-graphql (~> 0.31.1) + opentelemetry-instrumentation-ethon (~> 0.28.0) + opentelemetry-instrumentation-excon (~> 0.28.0) + opentelemetry-instrumentation-faraday (~> 0.32.0) + opentelemetry-instrumentation-grape (~> 0.6.0) + opentelemetry-instrumentation-graphql (~> 0.31.2) opentelemetry-instrumentation-grpc (~> 0.4.1) opentelemetry-instrumentation-gruf (~> 0.5.0) - opentelemetry-instrumentation-http (~> 0.28.0) - opentelemetry-instrumentation-http_client (~> 0.27.0) - opentelemetry-instrumentation-httpx (~> 0.6.0) + opentelemetry-instrumentation-http (~> 0.29.0) + opentelemetry-instrumentation-http_client (~> 0.28.0) + opentelemetry-instrumentation-httpx (~> 0.7.0) opentelemetry-instrumentation-koala (~> 0.23.0) opentelemetry-instrumentation-lmdb (~> 0.25.0) opentelemetry-instrumentation-mongo (~> 0.25.0) opentelemetry-instrumentation-mysql2 (~> 0.33.0) - opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-net_http (~> 0.28.0) opentelemetry-instrumentation-pg (~> 0.35.0) opentelemetry-instrumentation-que (~> 0.12.0) - opentelemetry-instrumentation-racecar (~> 0.6.0) - opentelemetry-instrumentation-rack (~> 0.29.0) - opentelemetry-instrumentation-rails (~> 0.39.1) + opentelemetry-instrumentation-racecar (~> 0.6.1) + opentelemetry-instrumentation-rack (~> 0.30.0) + opentelemetry-instrumentation-rails (~> 0.40.0) opentelemetry-instrumentation-rake (~> 0.5.0) opentelemetry-instrumentation-rdkafka (~> 0.9.0) opentelemetry-instrumentation-redis (~> 0.28.0) opentelemetry-instrumentation-resque (~> 0.8.0) - opentelemetry-instrumentation-restclient (~> 0.26.0) + opentelemetry-instrumentation-restclient (~> 0.27.0) opentelemetry-instrumentation-ruby_kafka (~> 0.24.0) opentelemetry-instrumentation-sidekiq (~> 0.28.1) - opentelemetry-instrumentation-sinatra (~> 0.28.0) - opentelemetry-instrumentation-trilogy (~> 0.66.0) - opentelemetry-instrumentation-anthropic (0.3.0) + opentelemetry-instrumentation-sinatra (~> 0.29.0) + opentelemetry-instrumentation-trilogy (~> 0.67.0) + opentelemetry-instrumentation-anthropic (0.4.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-aws_lambda (0.6.0) opentelemetry-instrumentation-base (~> 0.25) @@ -1000,13 +1001,13 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-delayed_job (0.25.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-ethon (0.27.0) + opentelemetry-instrumentation-ethon (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-excon (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-faraday (0.32.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-grape (0.5.1) + opentelemetry-instrumentation-grape (0.6.0) opentelemetry-instrumentation-rack (~> 0.29) opentelemetry-instrumentation-graphql (0.31.2) opentelemetry-instrumentation-base (~> 0.25) @@ -1014,11 +1015,11 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-gruf (0.5.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-http (0.29.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-http_client (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-httpx (0.6.1) + opentelemetry-instrumentation-httpx (0.7.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-koala (0.23.0) opentelemetry-instrumentation-base (~> 0.25) @@ -1031,7 +1032,7 @@ GEM opentelemetry-helpers-sql opentelemetry-helpers-sql-processor opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-net_http (0.28.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-pg (0.35.0) opentelemetry-helpers-sql @@ -1041,9 +1042,9 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-racecar (0.6.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-rack (0.30.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-rails (0.40.0) opentelemetry-instrumentation-action_mailer (~> 0.6) opentelemetry-instrumentation-action_pack (~> 0.15) opentelemetry-instrumentation-action_view (~> 0.11) @@ -1060,15 +1061,15 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-resque (0.8.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-restclient (0.26.0) + opentelemetry-instrumentation-restclient (0.27.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-ruby_kafka (0.24.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-sidekiq (0.28.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-sinatra (0.28.0) + opentelemetry-instrumentation-sinatra (0.29.0) opentelemetry-instrumentation-rack (~> 0.29) - opentelemetry-instrumentation-trilogy (0.66.0) + opentelemetry-instrumentation-trilogy (0.67.0) opentelemetry-helpers-mysql opentelemetry-helpers-sql opentelemetry-helpers-sql-processor @@ -1695,7 +1696,7 @@ DEPENDENCIES openproject-wikis! openproject-xls_export! opentelemetry-exporter-otlp (~> 0.31.0) - opentelemetry-instrumentation-all (~> 0.90.0) + opentelemetry-instrumentation-all (~> 0.91.0) opentelemetry-sdk (~> 1.10) overviews! ox @@ -2078,22 +2079,22 @@ CHECKSUMS openproject-xls_export (1.0.0) openssl (4.0.1) sha256=e27974136b7b02894a1bce46c5397ee889afafe704a839446b54dc81cb9c5f7d openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80 - opentelemetry-api (1.7.0) sha256=ccfd264ea6f2db5bf4185e3c07a1297977b44a944e2ce65457c4fe63a697214f + opentelemetry-api (1.8.0) sha256=3af51183daf0f56a164bc1579782245be70a40678566b9a393cbe5af28ea87c6 opentelemetry-common (0.23.0) sha256=da721190479d57bae0ad2207468f47f3e2c3b9a91024b5bc32c9d280183eb32c opentelemetry-exporter-otlp (0.31.1) sha256=5358be17d7849cbcc4f49e1fc24105edc780a6f96c8e57b64192ab9a8e47474a - opentelemetry-helpers-mysql (0.4.0) sha256=d309c0b20825bdd14d4dbc75e0d3b381ffdad37d16424ceca3cb8453d9cb5a4f + opentelemetry-helpers-mysql (0.5.0) sha256=8c2a5d5428aec271a7d2e25c158d06d4d8a914143b5004305964d1fcbc176eca opentelemetry-helpers-sql (0.3.0) sha256=4bb08017d6a16dd41c4d1c53c7fd30f9c5bb691195d8b458933724627b3f37f9 opentelemetry-helpers-sql-processor (0.4.0) sha256=ec238d7a2887219bd247dc31d0eb8a1a03d414a899963b68e14bb9f4d18b23f4 opentelemetry-instrumentation-action_mailer (0.6.1) sha256=8384866bdb066ae14b9a1fe686ffaf1f23468326a35af64390c0395fcd471057 - opentelemetry-instrumentation-action_pack (0.15.1) sha256=84fade740783caeebf260aaefcbf8f1a7a4c49f946944ff520a2fb1d6b07f273 + opentelemetry-instrumentation-action_pack (0.16.0) sha256=f4d54806b96dff89af31fb971fe5b1f79dd41fcc46489ed7c5340a47ee12a7f9 opentelemetry-instrumentation-action_view (0.11.2) sha256=e6a099015d672dabc19993d6fca99ef1e7210361ef21549a6e2076a67719fafc opentelemetry-instrumentation-active_job (0.10.1) sha256=aea1311224c20d064a8f218a44299171152dc36eeb531b9eba84bed8b3942a89 opentelemetry-instrumentation-active_model_serializers (0.24.0) sha256=8fe81e44167d17e45d9acfa588d20140c7640c323e58aca99e266de1bb3fce15 opentelemetry-instrumentation-active_record (0.11.1) sha256=1b083f34eea0449f8d6f4370b3fb4b935757fac6e4e538e67bb98211809e7c92 opentelemetry-instrumentation-active_storage (0.3.1) sha256=f89b0fef54921f17c0c4c38a6e0926d29afabd0ac98436fcdbb8bde85dfde89e opentelemetry-instrumentation-active_support (0.10.1) sha256=82ea98367158797e33c6de96581f10aa4fe8adf0ebec832dcff5fd04c59bc57d - opentelemetry-instrumentation-all (0.90.1) sha256=7c6a1cb321fbf320618e644a2a0572881eccb7f26d27b61b7079f2dc64fc88e4 - opentelemetry-instrumentation-anthropic (0.3.0) sha256=09bd9b4ba6189389a6c0f7ba49f1d11f387d93b411ab585137a48b59925a48de + opentelemetry-instrumentation-all (0.91.0) sha256=b077ce47da94e70e167157206034405f37ed0a4641d12ca8180a4b655c5727e2 + opentelemetry-instrumentation-anthropic (0.4.0) sha256=0040e0d97e9a66ef32cc35612ff28d7310d4ec1cd2f949805a2017f00f4d2de0 opentelemetry-instrumentation-aws_lambda (0.6.0) sha256=1a3161393cfe9bc9eddd81a0668d076c38a0a2c3d5df40e95d02f5a8fcd3334c opentelemetry-instrumentation-aws_sdk (0.11.0) sha256=67a21e754ddf51e2bb8c3e46e116aa9158d8db800f34c2a9b1e0da5a6ca911e3 opentelemetry-instrumentation-base (0.25.0) sha256=642a3a7f08354e6e969423327a4fa67ed2cca7ac6fe5ee09e55b17d1c576da27 @@ -2101,35 +2102,35 @@ CHECKSUMS opentelemetry-instrumentation-concurrent_ruby (0.24.0) sha256=229bd8b72000c59de693609bb637b8a9114992f5e0ab03730d7fd7ef91f7d1d2 opentelemetry-instrumentation-dalli (0.29.2) sha256=21b82772ced1529288c7f08285d44d5690de11f3d275e24558a062f39a270f4f opentelemetry-instrumentation-delayed_job (0.25.1) sha256=47f35b10d2bfd9ac7c2bbbe10dea095a2e25db2a84f5351860ead969d180c3ec - opentelemetry-instrumentation-ethon (0.27.0) sha256=bfd2e34a5f34c7114727b0e0c9d441a1c6c7a4cceb8374d90ae9332f009f3968 - opentelemetry-instrumentation-excon (0.27.0) sha256=3d7e6e160f0328e1136646aabb23efefdb125854637d5bd57b849720f783b5bb - opentelemetry-instrumentation-faraday (0.31.0) sha256=1c00dc96d4c18890a34a20eef27eae536bf6558965e03e254bb7b84a4f09840b - opentelemetry-instrumentation-grape (0.5.1) sha256=a623608ef10e96c413f4d50b840082bf1ab9700126185d89ddbc8a29b49ec0ef + opentelemetry-instrumentation-ethon (0.28.0) sha256=5ab5eb0733fec27300047f1f0906453171732c663d0484968ce0582026256b2d + opentelemetry-instrumentation-excon (0.28.0) sha256=00bfd0bce489d5f924ab81c440098e99b6e4234f8968f942ce0753e2a326b99b + opentelemetry-instrumentation-faraday (0.32.0) sha256=21f78858c4d8986a9b89a330bc1f6ef03007d6893d009865b4539269f686cdfd + opentelemetry-instrumentation-grape (0.6.0) sha256=bc6f0ac3416b42bf096032ab79193326d6b50b12e8ccbcf028a78a4df492d057 opentelemetry-instrumentation-graphql (0.31.2) sha256=a4455f225427f8f9058247c8c0b351b8932567913c35ef049f7958801d401b1f opentelemetry-instrumentation-grpc (0.4.1) sha256=5ffa2bb1d5ec69bcd1fe23e1d8c1a563a00351ce052fe9d76885cc43f21ebc87 opentelemetry-instrumentation-gruf (0.5.0) sha256=ee21be36e312e71b847c9a87168225625890121140a364b68d3668e0df58dacd - opentelemetry-instrumentation-http (0.28.0) sha256=0946a9593d64740780d16e041f9441945c4970b17566ce98e1a63bd3b276cacd - opentelemetry-instrumentation-http_client (0.27.0) sha256=6a639a21296d3b6d3934b2d5465bee5d5f9180208d0329a5bbe34b374030690e - opentelemetry-instrumentation-httpx (0.6.1) sha256=9050801826bb0f148f603b39b119c4f49591955ee53d3d0b2a8fb1eb8f558022 + opentelemetry-instrumentation-http (0.29.0) sha256=c2981f22dac791f1768595c08b5338d29ad57bd98e23e9a2c0df7a1dc54122f1 + opentelemetry-instrumentation-http_client (0.28.0) sha256=f6dadfed166d75d5632ae0b3521ed6a491080972923031489b85711e6d58fcb8 + opentelemetry-instrumentation-httpx (0.7.0) sha256=3928185b62066cf6d8fe3b011dc5587ba53b09a5c7b573e36481b8d713d6aa03 opentelemetry-instrumentation-koala (0.23.0) sha256=8f324b50a2a64fd4994bb2b105a4cb0c80b64ec05cf5487d2daa906c650bc6f9 opentelemetry-instrumentation-lmdb (0.25.0) sha256=1e4d66d583ea242d4f72051062971f5af1ea353484d224abbd0aabdd1ce5f5cb opentelemetry-instrumentation-mongo (0.25.0) sha256=d04585669f928ea82e7c469f996061d39d8ff184278d57cf4fc77a6d607f9c7a opentelemetry-instrumentation-mysql2 (0.33.0) sha256=b49b7957d5eef59e046e73be3ca370518965d61495745b4cb7ece3ef5470bcf9 - opentelemetry-instrumentation-net_http (0.27.0) sha256=f7ad7c5887646a09043b5f4e73717d9a897272f8032b883b0fadffbcc27f8990 + opentelemetry-instrumentation-net_http (0.28.0) sha256=63b00c1c8fcfba15cd293ece8383d19bbc35e9b5cc04056b3e95799be11026f5 opentelemetry-instrumentation-pg (0.35.0) sha256=65a6e78bd45282b56021f1ee1b88b9fd318abf6812c32bd740465e6b9997aad4 opentelemetry-instrumentation-que (0.12.0) sha256=3b7a84341f6af5a04f8c57860aeba4033f87c855d40c611a2fc40dde849944fb opentelemetry-instrumentation-racecar (0.6.1) sha256=833f6611906fb661f577e841d4ec52549474d32b4e8edea8048162348d35b845 - opentelemetry-instrumentation-rack (0.29.0) sha256=9e2cbb8336087064cbe33b502d917d85b174162bc717efda1cfdbd182342f377 - opentelemetry-instrumentation-rails (0.39.1) sha256=7959df7895543040fbb5cd3877c37bc9f95d79ff9d7749334314c50b871ac96f + opentelemetry-instrumentation-rack (0.30.0) sha256=30a54f7b44d4b91839622a20eb0b25a7c47084b37c2b03cfc149bfc4ef62303c + opentelemetry-instrumentation-rails (0.40.0) sha256=f794d477e8b48d9167ac1dbaf71dfc88e2a5647f76394cab7d1dfc6d5217b983 opentelemetry-instrumentation-rake (0.5.0) sha256=fa6bd019078975ac8a67eaea06294e4fe6707e6770d8ced88d74dc573b0a01ef opentelemetry-instrumentation-rdkafka (0.9.0) sha256=f3beb56828c584d7d91a2c46f6e5a2ef82289b1d4445b1eb5bc13b80ab6aca89 opentelemetry-instrumentation-redis (0.28.0) sha256=8721957d1c527dd22bd564d17f3a8db252081abb302be189511282d023693900 opentelemetry-instrumentation-resque (0.8.0) sha256=559edde9d6273dd757ae5149ed36e26d147b63028d084121203f51c8cff805e5 - opentelemetry-instrumentation-restclient (0.26.0) sha256=5d4e9d93ef51564a1023c076e17b4ac3b42fe81003321d9fb66e44886538bdce + opentelemetry-instrumentation-restclient (0.27.0) sha256=1abe208f5f43eff8648fa3ec3393c021bcbf30512f0fd69e4edbe8345ac3f899 opentelemetry-instrumentation-ruby_kafka (0.24.0) sha256=257e891f4ce630ba3e0669408d497b44afcc493cd49aed09343d5a51fa8952c2 opentelemetry-instrumentation-sidekiq (0.28.1) sha256=abc85d62996a5362e7a9fd7af9f6c709d01ce04795514d12fee5126335ae97ae - opentelemetry-instrumentation-sinatra (0.28.0) sha256=9f11d68c580a421cadd633aca1f8f92707d6b6995d48fffa045a48c187347f26 - opentelemetry-instrumentation-trilogy (0.66.0) sha256=f09dead2fc09b84e5f84a9e39bdd351486549724f47471766230774831ecbe70 + opentelemetry-instrumentation-sinatra (0.29.0) sha256=08595fec08d198df581d96aceb4b27998b84431e44a679950af7d00ab6559bdb + opentelemetry-instrumentation-trilogy (0.67.0) sha256=40394d3071d92aa418ef5aedab8e74f7683c0566c285a5418f75ca0586fd025f opentelemetry-registry (0.4.0) sha256=903fa6bfaa29eac1c1d73a4fdd29b850977b5353b84b8cdff11222c00ad2968f opentelemetry-sdk (1.10.0) sha256=43719949be8df24dcaeb86ebbf75636cda87d51a01af2729499b92a48b80521a opentelemetry-semantic_conventions (1.36.0) sha256=c1b1607dbc7853aac7f9e23f6e8b76969c45b07f2b812a4aa4383c19a3b0f617 From dce6e5439cd59294b9003adf22c8de8d2feb4ade Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:11:46 +0100 Subject: [PATCH 250/435] bump meta-tags --- Gemfile | 2 +- Gemfile.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 7021aacbc47..61e782c16aa 100644 --- a/Gemfile +++ b/Gemfile @@ -163,7 +163,7 @@ gem "matrix", "~> 0.4.3" gem "mcp", "~> 0.8.0" -gem "meta-tags", "~> 2.22.3" +gem "meta-tags", "~> 2.23.0" gem "paper_trail", "~> 17.0.0" diff --git a/Gemfile.lock b/Gemfile.lock index d37717711df..7eb0b5a342b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -823,7 +823,7 @@ GEM json-schema (>= 4.1) messagebird-rest (5.0.0) jwt (< 4) - meta-tags (2.22.3) + meta-tags (2.23.0) actionpack (>= 6.0.0) method_source (1.1.0) mime-types (3.7.0) @@ -1656,7 +1656,7 @@ DEPENDENCIES matrix (~> 0.4.3) mcp (~> 0.8.0) md_to_pdf! - meta-tags (~> 2.22.3) + meta-tags (~> 2.23.0) mini_magick (~> 5.3.0) multi_json (~> 1.19.0) my_page! @@ -2014,7 +2014,7 @@ CHECKSUMS mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb md_to_pdf (0.2.5) messagebird-rest (5.0.0) sha256=da4cc1efba3d5e4aa021fad07426c2cb6b326ce5670da5104bb8f6056a39d59c - meta-tags (2.22.3) sha256=41ead5437140869717cbdd659cc6f1caa3e498b3e74b03ed63503b5b38ed504f + meta-tags (2.23.0) sha256=ffe78b5bee398de4ff5ac3316f5a786049538a651643b8476def06c3acc762c1 method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 mime-types-data (3.2026.0317) sha256=77f078a4d8631d52b842ba77099734b06eddb7ad339d792e746d2272b67e511b From a4850824f231e4b31b79c8ea204b908f350e5112 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:12:34 +0100 Subject: [PATCH 251/435] bump opentelemetry-exporter-otlp --- Gemfile | 2 +- Gemfile.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 61e782c16aa..0a3e0ee2f70 100644 --- a/Gemfile +++ b/Gemfile @@ -236,7 +236,7 @@ gem "yabeda-puma-plugin" gem "yabeda-rails" # opentelemetry -gem "opentelemetry-exporter-otlp", "~> 0.31.0", require: false +gem "opentelemetry-exporter-otlp", "~> 0.32.0", require: false gem "opentelemetry-instrumentation-all", "~> 0.91.0", require: false gem "opentelemetry-sdk", "~> 1.10", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7eb0b5a342b..6ef5ff9841b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -914,7 +914,7 @@ GEM logger opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.31.1) + opentelemetry-exporter-otlp (0.32.0) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) @@ -1695,7 +1695,7 @@ DEPENDENCIES openproject-webhooks! openproject-wikis! openproject-xls_export! - opentelemetry-exporter-otlp (~> 0.31.0) + opentelemetry-exporter-otlp (~> 0.32.0) opentelemetry-instrumentation-all (~> 0.91.0) opentelemetry-sdk (~> 1.10) overviews! @@ -2081,7 +2081,7 @@ CHECKSUMS openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80 opentelemetry-api (1.8.0) sha256=3af51183daf0f56a164bc1579782245be70a40678566b9a393cbe5af28ea87c6 opentelemetry-common (0.23.0) sha256=da721190479d57bae0ad2207468f47f3e2c3b9a91024b5bc32c9d280183eb32c - opentelemetry-exporter-otlp (0.31.1) sha256=5358be17d7849cbcc4f49e1fc24105edc780a6f96c8e57b64192ab9a8e47474a + opentelemetry-exporter-otlp (0.32.0) sha256=fd4c77a07bb96919e8ff8bbd19ed96d07cac1e368d8e920af2bf2ab02bfb1ec3 opentelemetry-helpers-mysql (0.5.0) sha256=8c2a5d5428aec271a7d2e25c158d06d4d8a914143b5004305964d1fcbc176eca opentelemetry-helpers-sql (0.3.0) sha256=4bb08017d6a16dd41c4d1c53c7fd30f9c5bb691195d8b458933724627b3f37f9 opentelemetry-helpers-sql-processor (0.4.0) sha256=ec238d7a2887219bd247dc31d0eb8a1a03d414a899963b68e14bb9f4d18b23f4 From ba1e1fe391e0a9699c1e795de66e91ac2429a566 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:13:04 +0100 Subject: [PATCH 252/435] bump pagy --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6ef5ff9841b..8078699734c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1089,7 +1089,7 @@ GEM ostruct (0.6.3) ox (2.14.23) bigdecimal (>= 3.0) - pagy (43.3.3) + pagy (43.4.0) json uri yaml @@ -2139,7 +2139,7 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 overviews (1.0.0) ox (2.14.23) sha256=4a9aedb4d6c78c5ebac1d7287dc7cc6808e14a8831d7adb727438f6a1b461b66 - pagy (43.3.3) sha256=26b822c32ac5452f733736aa0e56bfd45d7fd02358c7d91c7d31bae61164e758 + pagy (43.4.0) sha256=5962b476bab4b96aa6029be76d537dd9597aa00454508aecee001517d9744a9b paper_trail (17.0.0) sha256=1c2842061d3874ca7015908e821e2aa14f9b982af2acb2a7974713bf79021c85 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parallel_tests (4.10.1) sha256=df05458c691462b210f7a41fc2651d4e4e8a881e8190e6d1e122c92c07735d70 From 8a71278572fc88897588bd552f2727d01eb944c2 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:13:41 +0100 Subject: [PATCH 253/435] bump puffing-billy --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8078699734c..80235f120e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1182,7 +1182,7 @@ GEM date stringio public_suffix (7.0.5) - puffing-billy (4.0.3) + puffing-billy (4.0.4) addressable (~> 2.5) cgi em-http-request (~> 1.1, >= 1.1.0) @@ -2173,7 +2173,7 @@ CHECKSUMS pry-rescue (1.6.0) sha256=985bfd506d9866b587fd86790cf8445266a41b7f92c627fc5b21ec7d92aba6db psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - puffing-billy (4.0.3) sha256=376fe2e2cc3ff9d48814a15153db80970cf0539ba026ac5108c971c2e160883c + puffing-billy (4.0.4) sha256=87015b0c41e0722b2171a0c5aa8130fd3f58aa1c016a1dc6dc569b2028aa846f puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 puma-plugin-statsd (2.7.0) sha256=04f243a7233f4d06ec0e26f1a3522bce18a5910ae711763fabff22681bdad08b raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 From 2a394f09631d7e84936cc5f863d59a29e36d2904 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:14:13 +0100 Subject: [PATCH 254/435] bump scimitar --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 80235f120e2..f0a903950ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1395,7 +1395,7 @@ GEM sanitize (7.0.0) crass (~> 1.0.2) nokogiri (>= 1.16.8) - scimitar (2.14.0) + scimitar (2.15.0) rails (>= 7.0) securerandom (0.4.1) selenium-devtools (0.143.0) @@ -2249,7 +2249,7 @@ CHECKSUMS rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 safety_net_attestation (0.5.0) sha256=c8cd01dd550dbe8553862918af6355a04672db11d218ec96104ce3955293f2aa sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984 - scimitar (2.14.0) sha256=93b29132ebd50d78e61d56c8a17406b1c01857448f2d075560fd6c7660ac9aa9 + scimitar (2.15.0) sha256=700901afab9303b705a8f37644cd733ee3c3819b168d24a14a48dec060b16d63 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-devtools (0.143.0) sha256=23e8b364e1074a93a56ea0365ff739022a23a72e9033ad69832400c884417dc4 selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22 From e2bfe78837cccde6c1338c11bdc7f1d47c0a23d4 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:14:58 +0100 Subject: [PATCH 255/435] bump retriable --- Gemfile | 2 +- Gemfile.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 0a3e0ee2f70..75d3214492b 100644 --- a/Gemfile +++ b/Gemfile @@ -277,7 +277,7 @@ group :test do gem "rspec-rails", "~> 8.0.4", group: :development # Retry failures within the same environment - gem "retriable", "~> 3.2.1" + gem "retriable", "~> 3.4.1" gem "rspec-retry", "~> 0.6.1" # Accessibility tests diff --git a/Gemfile.lock b/Gemfile.lock index f0a903950ae..c65c771b64e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1297,7 +1297,7 @@ GEM responders (3.2.0) actionpack (>= 7.0) railties (>= 7.0) - retriable (3.2.1) + retriable (3.4.1) rexml (3.4.4) rinku (2.0.6) roar (1.2.0) @@ -1728,7 +1728,7 @@ DEPENDENCIES redis (~> 5.4.0) request_store (~> 1.7.0) responders (~> 3.2) - retriable (~> 3.2.1) + retriable (~> 3.4.1) rinku (~> 2.0.4) roar (~> 1.2.0) rouge (~> 4.7.0) @@ -2213,7 +2213,7 @@ CHECKSUMS representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a - retriable (3.2.1) sha256=26e87a33391fae4c382d4750f1e135e4dda7e5aa32b6b71f1992265981f9b991 + retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rinku (2.0.6) sha256=8b60670e3143f3db2b37efa262971ce3619ec23092045498ef9f077d82828d7d roar (1.2.0) sha256=8db4d1ca79c57a5fb746c16c0d5661d7c3e0de3d9553dc016a88d2dba2929d08 From 893051cd352d8752128eb864292d3ab39e54be4f Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:15:51 +0100 Subject: [PATCH 256/435] bump commonmarker --- Gemfile | 2 +- Gemfile.lock | 28 +++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index 75d3214492b..dc07368007d 100644 --- a/Gemfile +++ b/Gemfile @@ -87,7 +87,7 @@ gem "htmldiff" gem "stringex", "~> 2.8.5" # CommonMark markdown parser with GFM extension -gem "commonmarker", "~> 2.6.0" +gem "commonmarker", "~> 2.7.0" # HTML pipeline for transformations on text formatter output # such as sanitization or additional features diff --git a/Gemfile.lock b/Gemfile.lock index c65c771b64e..14a37ef2e1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -438,12 +438,13 @@ GEM descendants_tracker (~> 0.0.1) color_conversion (0.1.2) colored2 (4.0.3) - commonmarker (2.6.3-aarch64-linux) - commonmarker (2.6.3-arm-linux) - commonmarker (2.6.3-arm64-darwin) - commonmarker (2.6.3-x86_64-darwin) - commonmarker (2.6.3-x86_64-linux) - commonmarker (2.6.3-x86_64-linux-musl) + commonmarker (2.7.0-aarch64-linux) + commonmarker (2.7.0-aarch64-linux-musl) + commonmarker (2.7.0-arm-linux) + commonmarker (2.7.0-arm64-darwin) + commonmarker (2.7.0-x86_64-darwin) + commonmarker (2.7.0-x86_64-linux) + commonmarker (2.7.0-x86_64-linux-musl) compare-xml (0.66) nokogiri (~> 1.8) concurrent-ruby (1.3.6) @@ -1596,7 +1597,7 @@ DEPENDENCIES climate_control closure_tree (~> 9.6.1) colored2 - commonmarker (~> 2.6.0) + commonmarker (~> 2.7.0) compare-xml (~> 0.66) connection_pool (~> 3.0.2) costs! @@ -1860,12 +1861,13 @@ CHECKSUMS coercible (1.0.0) sha256=5081ad24352cc8435ce5472bc2faa30260c7ea7f2102cc6a9f167c4d9bffaadc color_conversion (0.1.2) sha256=99bea5fa412e1527a11389975aa6ad445ff8528ebae202c11d08c45ea2b94c96 colored2 (4.0.3) sha256=63e1038183976287efc43034f5cca17fb180b4deef207da8ba78d051cbce2b37 - commonmarker (2.6.3-aarch64-linux) sha256=73795e80ab5ef1e4b5b83ada6f082bccb0ed7eae0b910232e13af1b2d71b14d6 - commonmarker (2.6.3-arm-linux) sha256=62b9f32d7d3f85d47988a4a98a2e66e60ca42b894687047db8332f1e80caff7b - commonmarker (2.6.3-arm64-darwin) sha256=d6c1e4955619da3f68fed22de99dec49a24925611770c039bf870823846c8b21 - commonmarker (2.6.3-x86_64-darwin) sha256=cd8ab974bb24f675a250ea91a811b3ff70405be1c219f0052446995db6ca90c6 - commonmarker (2.6.3-x86_64-linux) sha256=e861ba1812721113725ebd8e46e4fee20dc732842f5555db2cfb8dcd74056583 - commonmarker (2.6.3-x86_64-linux-musl) sha256=2c62d2dc0d5c4efc6dde39bc5c5fac292169206601a3daf75e562d70b795d49e + commonmarker (2.7.0-aarch64-linux) sha256=a15a47bb901f4cfcb3b4fe54d0daf91ec46e70b6e9704c67abec0ba30064b2dc + commonmarker (2.7.0-aarch64-linux-musl) sha256=fa08eb19ffc3cf2f7132d86d74622338a0c35b480b23814a7ae63deaeafb56d3 + commonmarker (2.7.0-arm-linux) sha256=85274d47feca40bd574ac49c2959a015464936f7f830c1e2918ee147d5be1a13 + commonmarker (2.7.0-arm64-darwin) sha256=ef00f7efef4822e8e7f88be88920a319f71a251e894e02fbc82f6db5d4291db3 + commonmarker (2.7.0-x86_64-darwin) sha256=a8f5928f36138347b5e672d080b8f76dc430c6207566128b117cdd02f3e48c34 + commonmarker (2.7.0-x86_64-linux) sha256=53243eeb30e4ddb6a474672597ff7e59334e55a7653126bb87e65e6ab2629a4c + commonmarker (2.7.0-x86_64-linux-musl) sha256=e4f5c06975f37a405bd157d276609e1c02502d2fbaf576f6e3616fba2e7b5662 compare-xml (0.66) sha256=e21aa5c0f69ef1177eced997c688fd4df989084e74a1b612257af32e1dd05319 concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a From 99ad7fa1c1912c2bec4283839ae046e69c6056c5 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:16:56 +0100 Subject: [PATCH 257/435] bump selenium-devtools --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 14a37ef2e1d..264cf7ad8b9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1399,7 +1399,7 @@ GEM scimitar (2.15.0) rails (>= 7.0) securerandom (0.4.1) - selenium-devtools (0.143.0) + selenium-devtools (0.145.0) selenium-webdriver (~> 4.2) selenium-webdriver (4.41.0) base64 (~> 0.2) @@ -2253,7 +2253,7 @@ CHECKSUMS sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984 scimitar (2.15.0) sha256=700901afab9303b705a8f37644cd733ee3c3819b168d24a14a48dec060b16d63 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - selenium-devtools (0.143.0) sha256=23e8b364e1074a93a56ea0365ff739022a23a72e9033ad69832400c884417dc4 + selenium-devtools (0.145.0) sha256=629c4964ebeb140f0e08cd6e88e241b07a995416a0e45d8416ba25cf4a7f0513 selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22 semantic (1.6.1) sha256=3cdbb48f59198ebb782a3fdfb87b559e0822a311610db153bae22777a7d0c163 shoulda-context (2.0.0) sha256=7adf45342cd800f507d2a053658cb1cce2884b616b26004d39684b912ea32c34 From 060ea8b11f2025b704e3427c950b5ed26a926dff Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:17:34 +0100 Subject: [PATCH 258/435] bump yabeda --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 264cf7ad8b9..26b21e112cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1530,7 +1530,7 @@ GEM zeitwerk (>= 2.7) xpath (3.2.0) nokogiri (~> 1.8) - yabeda (0.15.0) + yabeda (0.16.0) anyway_config (>= 1.0, < 3) concurrent-ruby dry-initializer @@ -2317,7 +2317,7 @@ CHECKSUMS will_paginate (4.0.1) sha256=107b226ebe1d393d274575956a7c472e1eefdd97d8828e01b72d425d15a875b9 with_advisory_lock (7.5.0) sha256=dadb2f1ed35a10ed7b9649a6769e6848bc64f735a85eb8a6e162a81d383a15bf xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e - yabeda (0.15.0) sha256=81ce61c6e89d42ac3c5855aa2dcddfc2347c33d1595372d59d79e7dda4e72238 + yabeda (0.16.0) sha256=7f6e51acd7d9a51d850ea8c3844f72a24882f1312b3fc3836052bcd63d384cba yabeda-activerecord (0.1.2) sha256=1dd281a64e5742445a6718aa05e799ea08a397e9ab9c0d254ece447635a3e0e2 yabeda-prometheus-mmap (0.4.0) sha256=1a66120756d6f931f03a7784e08e79060d71681ff83a9f5287df2ff756e9e2c9 yabeda-puma-plugin (0.9.0) sha256=b78673ecc7ee30bc50691ddc41b7022c1c1801843900d5101418f4a14b550bc8 From 58e0efda7e1fa2062627a17366ec94f36115ac36 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 18 Mar 2026 18:22:26 +0100 Subject: [PATCH 259/435] bump redis-client & rubocop-ast & webmock --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 26b21e112cf..a54afe83f93 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1284,7 +1284,7 @@ GEM redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.26.4) + redis-client (0.28.0) connection_pool regexp_parser (2.11.3) reline (0.6.3) @@ -1342,7 +1342,7 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-capybara (2.22.1) @@ -1514,7 +1514,7 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.26.1) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -2209,7 +2209,7 @@ CHECKSUMS recaptcha (5.21.1) sha256=e003e9ceba9b993a9f0c6a828c192f2d46693cd2aa0b0beae94f936649507adb redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445 redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae - redis-client (0.26.4) sha256=3ad70beff5da2653e02dfeae996e7d8d7147a558da12b16b2282ad345e4c7120 + redis-client (0.28.0) sha256=888892f9cd8787a41c0ece00bdf5f556dfff7770326ce40bb2bc11f1bfec824b regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace @@ -2230,7 +2230,7 @@ CHECKSUMS rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rspec-wait (1.0.2) sha256=865f921239325d3d26fc10ded4bdd485d8b58bcaaad1a28dd85ed15266b5a912 rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77 - rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-capybara (2.22.1) sha256=ced88caef23efea53f46e098ff352f8fc1068c649606ca75cb74650970f51c0c rubocop-factory_bot (2.28.0) sha256=4b17fc02124444173317e131759d195b0d762844a71a29fe8139c1105d92f0cb rubocop-openproject (0.3.0) sha256=9554496e7ef0a2cf65dc2b32bee1bfa223b4f9ae058a5c603489d34e9001a828 @@ -2309,7 +2309,7 @@ CHECKSUMS warden-basic_auth (0.2.1) sha256=bfc752e0109c0182c3e69e930284c5e1e81e7b4a354aeb2b5914ead1391f3c6e webauthn (3.4.3) sha256=9be6f5f838f3405b0226e560aa40b67cc8c15ec9154509b997caa7ec9a05e1fc webfinger (2.1.3) sha256=567a52bde77fb38ca6b67e55db755f988766ec4651c1d24916a65aa70540695c - webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 From 6d21fb55e0b6c167f69adf33042b69bc19a9b554 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:54:29 +0000 Subject: [PATCH 260/435] Bump loofah from 2.25.0 to 2.25.1 Bumps [loofah](https://github.com/flavorjones/loofah) from 2.25.0 to 2.25.1. - [Release notes](https://github.com/flavorjones/loofah/releases) - [Changelog](https://github.com/flavorjones/loofah/blob/main/CHANGELOG.md) - [Commits](https://github.com/flavorjones/loofah/compare/v2.25.0...v2.25.1) --- updated-dependencies: - dependency-name: loofah dependency-version: 2.25.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 48a73a9c7d0..122b3ce2d4c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -795,7 +795,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lookbook (2.3.14) @@ -2004,7 +2004,7 @@ CHECKSUMS lobby_boy (0.1.3) sha256=9460bb3c052aef158eb3f137b8f7679ca756b8e2983d140dbdc0caa85c018172 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 lograge (0.14.0) sha256=42371a75823775f166f727639f5ddce73dd149452a55fc94b90c303213dc9ae1 - loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 lookbook (2.3.14) sha256=c11a693bde9915b553c4463440ad5e750829f90bff08abdb6b8610373864cd7c mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 From fc9e8e56c055258d6360f31c99c6e59600749fc0 Mon Sep 17 00:00:00 2001 From: Tom Hykel Date: Wed, 18 Mar 2026 23:42:43 +0100 Subject: [PATCH 261/435] Update app/models/project.rb Co-authored-by: Kabiru Mwenja --- app/models/project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 2406a05b1b7..57be614e8f6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -215,7 +215,7 @@ class Project < ApplicationRecord p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric? } - # When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules. + # When semantic work package IDs with alphanumeric mode are active, identifiers must follow semantic style key rules. validates :identifier, format: { with: /\A[A-Z]/, message: :must_start_with_letter }, if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } From bab168081a74f1d738ee9443eb04883e345eba22 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 18 Mar 2026 23:44:51 +0100 Subject: [PATCH 262/435] simplify the special character validation rule --- app/models/project.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 57be614e8f6..7bbd077f6ca 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -221,12 +221,9 @@ class Project < ApplicationRecord if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } validates :identifier, - format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, + format: { with: /[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH }, - if: ->(p) { - p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? && - p.identifier.match?(/\A[A-Z]/) - } + if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } validates_associated :repository, :wiki From 7799ca7cbb0dd5f2d1f0ce67a492fcb1da85c114 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 01:01:12 +0100 Subject: [PATCH 263/435] revert the no_authorization_required clause --- app/controllers/projects/identifier_suggestions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/projects/identifier_suggestions_controller.rb b/app/controllers/projects/identifier_suggestions_controller.rb index f80e8aeaad0..0c6fc43fa04 100644 --- a/app/controllers/projects/identifier_suggestions_controller.rb +++ b/app/controllers/projects/identifier_suggestions_controller.rb @@ -31,7 +31,7 @@ module Projects class IdentifierSuggestionsController < ApplicationController before_action :require_login - no_authorization_required! :show + before_action :authorize def show name = params[:name].to_s.strip From 8f7465be4156968676e26f3f017d0eac0d3d82af Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 01:02:03 +0100 Subject: [PATCH 264/435] add comment to legacy ID suggestor --- app/models/project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 9fba9288efc..abb02ae2fa9 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -282,7 +282,7 @@ class Project < ApplicationRecord def self.suggest_identifier(name) if Setting::WorkPackageIdentifier.alphanumeric? WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.suggest_identifier(name) - else + else # This should closely enough emulate Project models' usage of acts_as_url name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project" end end From 4bb508003d1404c62f7128fb5cf689d7f4d5cc41 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 01:05:33 +0100 Subject: [PATCH 265/435] integrate stimulus-use/use-debounce --- .../projects/identifier-suggestion.controller.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts index 95e18af51c5..4714135f737 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -28,15 +28,16 @@ * ++ */ -import {Controller} from '@hotwired/stimulus'; -import {debounce, DebouncedFunc} from 'lodash'; +import {ApplicationController, useDebounce} from 'stimulus-use'; const ALLOWED_CHARS:Record = { semantic: /[^A-Z0-9_]/g, legacy: /[^a-z0-9\-_]/g, }; -export default class extends Controller { +export default class extends ApplicationController { + static debounces = ['fetchSuggestion']; + static values = { url: String, debounce: {type: Number, default: 300}, @@ -51,7 +52,6 @@ export default class extends Controller { private nameInput:HTMLInputElement | null = null; private identifierInput:HTMLInputElement | null = null; - private debouncedSuggest:DebouncedFunc<(name:string) => Promise> | null = null; private handleBlur:((event:Event) => void) | null = null; private handleInput:((event:Event) => void) | null = null; @@ -70,14 +70,11 @@ export default class extends Controller { this.identifierInput.readOnly = true; } - this.debouncedSuggest = debounce( - (name:string) => this.fetchSuggestion(name), - this.debounceValue, - ); + useDebounce(this, { wait: this.debounceValue }); this.handleBlur = () => { const name = this.nameInput!.value.trim(); - if (name) void this.debouncedSuggest!(name); + if (name) void this.fetchSuggestion(name); }; this.nameInput.addEventListener('blur', this.handleBlur); @@ -85,7 +82,6 @@ export default class extends Controller { } disconnect():void { - this.debouncedSuggest?.cancel(); if (this.nameInput && this.handleBlur) { this.nameInput.removeEventListener('blur', this.handleBlur); } From d3e8b201b844d640584de36f5360674e78ca04d1 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 01:08:24 +0100 Subject: [PATCH 266/435] leverage Stimulus targets --- .../identifier-suggestion.controller.ts | 59 ++++++++----------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts index 4714135f737..381586ffd66 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -37,6 +37,7 @@ const ALLOWED_CHARS:Record = { export default class extends ApplicationController { static debounces = ['fetchSuggestion']; + static targets = ['name', 'identifier']; static values = { url: String, @@ -50,68 +51,64 @@ export default class extends ApplicationController { declare modeValue:string; declare setNameFirstValue:string; - private nameInput:HTMLInputElement | null = null; - private identifierInput:HTMLInputElement | null = null; + declare readonly nameTarget:HTMLInputElement; + declare readonly identifierTarget:HTMLInputElement; + declare readonly hasNameTarget:boolean; + declare readonly hasIdentifierTarget:boolean; + private handleBlur:((event:Event) => void) | null = null; private handleInput:((event:Event) => void) | null = null; connect():void { - this.nameInput = this.element.querySelector('[name="project[name]"]'); - this.identifierInput = this.element.querySelector('[name="project[identifier]"]'); - - if (!this.nameInput || !this.identifierInput) return; + if (!this.hasNameTarget || !this.hasIdentifierTarget) return; this.handleInput = () => this.filterInput(); - this.identifierInput.addEventListener('input', this.handleInput); + this.identifierTarget.addEventListener('input', this.handleInput); if (this.urlValue) { - if (!this.identifierInput.value) { - this.identifierInput.placeholder = this.setNameFirstValue; - this.identifierInput.readOnly = true; + if (!this.identifierTarget.value) { + this.identifierTarget.placeholder = this.setNameFirstValue; + this.identifierTarget.readOnly = true; } useDebounce(this, { wait: this.debounceValue }); this.handleBlur = () => { - const name = this.nameInput!.value.trim(); + const name = this.nameTarget.value.trim(); if (name) void this.fetchSuggestion(name); }; - this.nameInput.addEventListener('blur', this.handleBlur); + this.nameTarget.addEventListener('blur', this.handleBlur); } } disconnect():void { - if (this.nameInput && this.handleBlur) { - this.nameInput.removeEventListener('blur', this.handleBlur); + if (this.hasNameTarget && this.handleBlur) { + this.nameTarget.removeEventListener('blur', this.handleBlur); } - if (this.identifierInput && this.handleInput) { - this.identifierInput.removeEventListener('input', this.handleInput); + if (this.hasIdentifierTarget && this.handleInput) { + this.identifierTarget.removeEventListener('input', this.handleInput); } } private filterInput():void { - if (!this.identifierInput) return; - const pattern = ALLOWED_CHARS[this.modeValue] ?? ALLOWED_CHARS.legacy; - const current = this.identifierInput.value; + const current = this.identifierTarget.value; const filtered = current.replace(pattern, ''); if (filtered !== current) { - const pos = this.identifierInput.selectionStart ?? filtered.length; - this.identifierInput.value = filtered; + const pos = this.identifierTarget.selectionStart ?? filtered.length; + this.identifierTarget.value = filtered; const newPos = Math.min(pos, filtered.length); - this.identifierInput.setSelectionRange(newPos, newPos); + this.identifierTarget.setSelectionRange(newPos, newPos); } } private async fetchSuggestion(name:string):Promise { if (!this.urlValue) return; - if (this.identifierInput) { - this.identifierInput.readOnly = true; - this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.loading'); - } + this.identifierTarget.readOnly = true; + this.identifierTarget.placeholder = I18n.t('js.projects.identifier_suggestion.loading'); try { const url = `${this.urlValue}?name=${encodeURIComponent(name)}`; @@ -120,14 +117,10 @@ export default class extends ApplicationController { if (!response.ok) return; const data = await response.json() as { identifier:string }; - if (this.identifierInput) { - this.identifierInput.value = data.identifier; - } + this.identifierTarget.value = data.identifier; } finally { - if (this.identifierInput) { - this.identifierInput.readOnly = false; - this.identifierInput.placeholder = ''; - } + this.identifierTarget.readOnly = false; + this.identifierTarget.placeholder = ''; } } } From d43ec5b446d7124e5f9cf04528bd9b53fea6846d Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 01:12:32 +0100 Subject: [PATCH 267/435] switch to AbortController#signal --- .../identifier-suggestion.controller.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts index 381586ffd66..11f175b9d33 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -56,14 +56,15 @@ export default class extends ApplicationController { declare readonly hasNameTarget:boolean; declare readonly hasIdentifierTarget:boolean; - private handleBlur:((event:Event) => void) | null = null; - private handleInput:((event:Event) => void) | null = null; + private abortController:AbortController | null = null; connect():void { if (!this.hasNameTarget || !this.hasIdentifierTarget) return; - this.handleInput = () => this.filterInput(); - this.identifierTarget.addEventListener('input', this.handleInput); + this.abortController = new AbortController(); + const { signal } = this.abortController; + + this.identifierTarget.addEventListener('input', () => this.filterInput(), { signal }); if (this.urlValue) { if (!this.identifierTarget.value) { @@ -73,25 +74,21 @@ export default class extends ApplicationController { useDebounce(this, { wait: this.debounceValue }); - this.handleBlur = () => { + this.nameTarget.addEventListener('blur', () => { const name = this.nameTarget.value.trim(); if (name) void this.fetchSuggestion(name); - }; - - this.nameTarget.addEventListener('blur', this.handleBlur); + }, { signal }); } } disconnect():void { - if (this.hasNameTarget && this.handleBlur) { - this.nameTarget.removeEventListener('blur', this.handleBlur); - } - if (this.hasIdentifierTarget && this.handleInput) { - this.identifierTarget.removeEventListener('input', this.handleInput); - } + this.abortController?.abort(); + this.abortController = null; } private filterInput():void { + if (!this.hasIdentifierTarget) return; + const pattern = ALLOWED_CHARS[this.modeValue] ?? ALLOWED_CHARS.legacy; const current = this.identifierTarget.value; const filtered = current.replace(pattern, ''); @@ -105,7 +102,7 @@ export default class extends ApplicationController { } private async fetchSuggestion(name:string):Promise { - if (!this.urlValue) return; + if (!this.urlValue || !this.hasIdentifierTarget) return; this.identifierTarget.readOnly = true; this.identifierTarget.placeholder = I18n.t('js.projects.identifier_suggestion.loading'); From b8f1bd2362d005c0d4507ba6d5dd1f3c0afa6fa9 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 01:16:17 +0100 Subject: [PATCH 268/435] remove the useless preregistration --- frontend/src/stimulus/setup.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index aa975c62b62..5bd0cd329ff 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -83,7 +83,6 @@ OpenProjectStimulusApplication.preregister('external-links', ExternalLinksContro OpenProjectStimulusApplication.preregister('highlight-target-element', HighlightTargetElementController); OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeController); OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); -OpenProjectStimulusApplication.preregister('projects--identifier-suggestion', IdentifierSuggestionController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); From 5f1232aa255320f7dd287db6b79628dc7a026bcc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 18 Mar 2026 20:40:16 -0700 Subject: [PATCH 269/435] Simplify sprint component spec guards --- .../backlogs/sprint_component_spec.rb | 2 +- .../backlogs/sprint_header_component_spec.rb | 2 +- .../backlogs/sprint_menu_component_spec.rb | 110 +++++++++--------- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 2dc14e060bf..bc56e0db62e 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::SprintComponent, type: :component, with_flag: { scrum_projects: true } do +RSpec.describe Backlogs::SprintComponent, type: :component do include Rails.application.routes.url_helpers shared_let(:type_feature) { create(:type_feature) } diff --git a/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb index ea79c4428be..2318fd41172 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_header_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::SprintHeaderComponent, type: :component, with_flag: { scrum_projects: true } do +RSpec.describe Backlogs::SprintHeaderComponent, type: :component do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } shared_let(:default_status) { create(:default_status) } diff --git a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb index aa1378cf694..325ec50a52e 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe Backlogs::SprintMenuComponent, type: :component, with_flag: { scrum_projects: true } do +RSpec.describe Backlogs::SprintMenuComponent, type: :component do shared_let(:type_feature) { create(:type_feature) } shared_let(:type_task) { create(:type_task) } @@ -109,74 +109,74 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component, with_flag: { scr describe "task board actions" do let(:permissions) { %i[view_sprints view_work_packages] } - context "with the feature flag active", with_flag: { scrum_projects: true } do - context "when the sprint is active" do - let(:sprint) do + context "when the sprint is active" do + let(:sprint) do + create(:agile_sprint, + project:, + name: "Sprint 1", + start_date: Date.yesterday, + finish_date: Date.tomorrow, + status: "active") + end + let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } + + it "shows Finish sprint first and Task board after Stories/Tasks" do + render_component + + expect(menu_items.first).to eq("Finish sprint") + expect(page).to have_octicon(:check) + expect(page).to have_css( + "form[action='#{finish_sprint_path}'][data-turbo='false'] " \ + "input[name='_method'][value='patch']", + visible: :hidden + ) + expect(menu_items).to include("Stories/Tasks", "Task board") + expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") + end + end + + context "when the sprint is in planning and the user can start it" do + let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } + + it "shows Start sprint as the first item" do + render_component + + expect(menu_items.first).to eq("Start sprint") + expect(page).to have_octicon(:play) + expect(page).to have_no_selector(:menuitem, text: "Task board") + expect(page).to have_css( + "form[action='#{start_sprint_path}'][data-turbo='false'] " \ + "input[name='_method'][value='patch']", + visible: :hidden + ) + end + + context "when another sprint is already active" do + let!(:active_sprint) do create(:agile_sprint, project:, - name: "Sprint 1", + name: "Sprint 2", start_date: Date.yesterday, finish_date: Date.tomorrow, status: "active") end - let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } - it "shows Finish sprint first and Task board after Stories/Tasks" do + it "shows Start sprint disabled with a description" do render_component - expect(menu_items.first).to eq("Finish sprint") - expect(page).to have_octicon(:check) - expect(page).to have_css( - "form[action='#{finish_sprint_path}'][data-turbo='false'] " \ - "input[name='_method'][value='patch']", - visible: :hidden + expect(menu_items.first).to include("Start sprint") + expect(page).to have_selector( + :menuitem, + text: "Start sprint", + disabled: true ) - expect(menu_items).to include("Stories/Tasks", "Task board") - expect(menu_items.index("Task board")).to be > menu_items.index("Stories/Tasks") - end - end - - context "when the sprint is in planning and the user can start it" do - let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } - - it "shows Start sprint as the first item" do - render_component - - expect(menu_items.first).to eq("Start sprint") - expect(page).to have_octicon(:play) - expect(page).to have_no_selector(:menuitem, text: "Task board") - expect(page).to have_css( - "form[action='#{start_sprint_path}'][data-turbo='false'] " \ - "input[name='_method'][value='patch']", - visible: :hidden - ) - end - - context "when another sprint is already active" do - let!(:active_sprint) do - create(:agile_sprint, - project:, - name: "Sprint 2", - start_date: Date.yesterday, - finish_date: Date.tomorrow, - status: "active") - end - - it "shows Start sprint disabled with a description" do - render_component - - expect(menu_items.first).to include("Start sprint") - expect(page).to have_selector( - :menuitem, - text: "Start sprint", - disabled: true - ) - expect(page).to have_text("Another sprint is already active.") - end + expect(page).to have_text("Another sprint is already active.") end end context "when the sprint is in planning and the user cannot start it" do + let(:permissions) { %i[view_sprints view_work_packages] } + it "does not show task-board-related items" do render_component From af7211356f1332e372ca808c2acf87a04adcf802 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 18 Mar 2026 23:11:59 -0700 Subject: [PATCH 270/435] Align sprint boards with shared sprints --- .../backlogs/sprint_component.html.erb | 2 +- .../components/backlogs/sprint_component.rb | 7 ++- .../backlogs/sprint_header_component.rb | 5 +- .../backlogs/sprint_menu_component.rb | 12 +++-- .../app/controllers/rb_sprints_controller.rb | 6 ++- .../app/controllers/rb_stories_controller.rb | 2 +- .../controllers/rb_taskboards_controller.rb | 2 +- modules/backlogs/app/models/agile/sprint.rb | 14 +++--- .../app/services/sprints/start_service.rb | 3 +- .../rb_master_backlogs/_agile_list.html.erb | 2 +- .../backlogs/sprint_menu_component_spec.rb | 46 +++++++++++++++++++ .../controllers/rb_sprints_controller_spec.rb | 36 +++++++++++++-- .../rb_taskboards_controller_spec.rb | 28 +++++++++++ .../features/sprints/start_finish_spec.rb | 2 +- .../backlogs/spec/models/agile/sprint_spec.rb | 25 ++++++---- .../services/sprints/start_service_spec.rb | 28 ++++++++--- modules/boards/app/models/boards/grid.rb | 2 +- .../boards/spec/models/boards/grid_spec.rb | 7 ++- 18 files changed, 182 insertions(+), 47 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index fc6fe3ac72b..36e7e88bcc6 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper(tag: :section) do %> <%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %> <% border_box.with_header(id: dom_target(sprint, :header)) do %> - <%= render(Backlogs::SprintHeaderComponent.new(sprint:, folded: folded?)) %> + <%= render(Backlogs::SprintHeaderComponent.new(sprint:, project:, folded: folded?)) %> <% end %> <% if stories.empty? %> <% border_box.with_row(data: { empty_list_item: true }) do %> diff --git a/modules/backlogs/app/components/backlogs/sprint_component.rb b/modules/backlogs/app/components/backlogs/sprint_component.rb index 19758de4839..6b38e1d6fb3 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_component.rb @@ -34,14 +34,13 @@ module Backlogs include OpTurbo::Streamable include RbCommonHelper - attr_reader :sprint, :current_user + attr_reader :sprint, :project, :current_user - delegate :project, to: :sprint - - def initialize(sprint:, current_user: User.current, **system_arguments) + def initialize(sprint:, project: sprint.project, current_user: User.current, **system_arguments) super() @sprint = sprint + @project = project @current_user = current_user @system_arguments = system_arguments diff --git a/modules/backlogs/app/components/backlogs/sprint_header_component.rb b/modules/backlogs/app/components/backlogs/sprint_header_component.rb index 968276e1d50..fd537447b01 100644 --- a/modules/backlogs/app/components/backlogs/sprint_header_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_header_component.rb @@ -36,19 +36,20 @@ module Backlogs include Redmine::I18n include RbCommonHelper - attr_reader :sprint, :collapsed, :current_user + attr_reader :sprint, :project, :collapsed, :current_user - delegate :project, to: :sprint delegate :name, to: :sprint, prefix: :sprint def initialize( sprint:, + project: sprint.project, folded: false, current_user: User.current ) super() @sprint = sprint + @project = project @collapsed = folded @current_user = current_user end diff --git a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb index 7ae7db35d69..5aa44bc0a42 100644 --- a/modules/backlogs/app/components/backlogs/sprint_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_menu_component.rb @@ -57,15 +57,15 @@ module Backlogs private def show_task_board_link? - !sprint.in_planning? + sprint.task_board_for(project).present? end def show_start_sprint_action? - sprint.in_planning? && user_allowed?(:start_complete_sprint) + sprint.project == project && sprint.in_planning? && user_allowed_in_source_project?(:start_complete_sprint) end def show_finish_sprint_action? - sprint.active? && user_allowed?(:start_complete_sprint) + sprint.project == project && sprint.active? && user_allowed_in_source_project?(:start_complete_sprint) end def disable_start_sprint_action? @@ -82,13 +82,17 @@ module Backlogs current_user.allowed_in_project?(permission, project) end + def user_allowed_in_source_project?(permission) + current_user.allowed_in_project?(permission, sprint.project) + end + def available_story_types @available_story_types ||= story_types & project.types end def project_has_another_active_sprint? @project_has_another_active_sprint ||= Agile::Sprint - .for_project(project) + .for_project(sprint.project) .where(status: "active") .where.not(id: sprint.id) .exists? diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index afb5b2611c8..aa6d00e5ef2 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -106,13 +106,14 @@ class RbSprintsController < RbApplicationController end def start + return render_404 unless @project == @sprint.project return render_404 unless @sprint.in_planning? result = start_sprint if result.success? @sprint = result.result - redirect_to project_work_package_board_path(@project, @sprint.task_board), + redirect_to project_work_package_board_path(@project, @sprint.task_board_for(@project)), notice: I18n.t(:notice_successful_start) else respond_with_start_failure(message: start_failure_message(result.message)) @@ -120,6 +121,7 @@ class RbSprintsController < RbApplicationController end def finish + return render_404 unless @project == @sprint.project return render_404 unless @sprint.active? result = finish_sprint @@ -181,7 +183,7 @@ class RbSprintsController < RbApplicationController def update_sprint_header_component_via_turbo_stream(sprint:) update_via_turbo_stream( - component: Backlogs::SprintHeaderComponent.new(sprint:), + component: Backlogs::SprintHeaderComponent.new(sprint:, project: @project), method: :morph ) end diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index c9ab6c288b2..7c284b02801 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -184,7 +184,7 @@ class RbStoriesController < RbApplicationController end def replace_sprint_component_via_turbo_stream(sprint:) - replace_via_turbo_stream(component: Backlogs::SprintComponent.new(sprint: sprint)) + replace_via_turbo_stream(component: Backlogs::SprintComponent.new(sprint:, project: @project)) end def legacy_load_story diff --git a/modules/backlogs/app/controllers/rb_taskboards_controller.rb b/modules/backlogs/app/controllers/rb_taskboards_controller.rb index d6d18c692ee..ceeeceeb68f 100644 --- a/modules/backlogs/app/controllers/rb_taskboards_controller.rb +++ b/modules/backlogs/app/controllers/rb_taskboards_controller.rb @@ -35,7 +35,7 @@ class RbTaskboardsController < RbApplicationController def show if OpenProject::FeatureDecisions.scrum_projects_active? - @board = @sprint.task_board + @board = @sprint.task_board_for(@project) return redirect_to(project_work_package_board_path(@project, @board)) if @board diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index ffdbd625069..68c7161f11a 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -39,11 +39,11 @@ module Agile belongs_to :project has_many :work_packages, dependent: :nullify - has_one :task_board, - as: :linked, - class_name: "Boards::Grid", - inverse_of: :linked, - dependent: :nullify + has_many :task_boards, + as: :linked, + class_name: "Boards::Grid", + inverse_of: :linked, + dependent: :nullify scopes :for_project, :not_completed, @@ -88,8 +88,8 @@ module Agile "#{project.name}: #{name}" end - def task_board? - task_board.present? + def task_board_for(project) + task_boards.find_by(project:) end end end diff --git a/modules/backlogs/app/services/sprints/start_service.rb b/modules/backlogs/app/services/sprints/start_service.rb index 7fbb71bee58..89d176a631f 100644 --- a/modules/backlogs/app/services/sprints/start_service.rb +++ b/modules/backlogs/app/services/sprints/start_service.rb @@ -70,7 +70,8 @@ class Sprints::StartService < BaseServices::BaseCallable end def ensure_task_board - return ServiceResult.success(result: model.task_board) if model.task_board? + existing_board = model.task_board_for(model.project) + return ServiceResult.success(result: existing_board) if existing_board.present? Boards::SprintTaskBoardCreateService .new(user:) diff --git a/modules/backlogs/app/views/rb_master_backlogs/_agile_list.html.erb b/modules/backlogs/app/views/rb_master_backlogs/_agile_list.html.erb index 412df5cf8ff..4e83ad1090a 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/_agile_list.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/_agile_list.html.erb @@ -42,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details. <% else %>
- <%= render(Backlogs::SprintComponent.with_collection(@sprints)) %> + <%= render(Backlogs::SprintComponent.with_collection(@sprints, project: @project)) %>
<%= render(Backlogs::BacklogComponent.with_collection(@owner_backlogs, project: @project)) %> diff --git a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb index 325ec50a52e..6f7cdc8f89b 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_menu_component_spec.rb @@ -119,6 +119,7 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do status: "active") end let(:permissions) { %i[view_sprints view_work_packages start_complete_sprint] } + let!(:task_board) { create(:board_grid_with_query, project:, linked: sprint) } it "shows Finish sprint first and Task board after Stories/Tasks" do render_component @@ -194,6 +195,7 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do finish_date: Date.tomorrow, status: "completed") end + let!(:task_board) { create(:board_grid_with_query, project:, linked: sprint) } it "shows Task board after Stories/Tasks" do render_component @@ -203,6 +205,50 @@ RSpec.describe Backlogs::SprintMenuComponent, type: :component do end end end + + context "when the sprint is rendered in a receiving project" do + let(:source_project) { create(:project, sprint_sharing: "share_all_projects", types: [type_feature, type_task]) } + let(:project) { create(:project, sprint_sharing: "receive_shared", types: [type_feature, type_task]) } + let(:sprint) do + create(:agile_sprint, + project: source_project, + name: "Shared Sprint", + start_date: Date.yesterday, + finish_date: Date.tomorrow, + status: "active") + end + let(:permissions) { %i[view_sprints view_work_packages create_sprints manage_sprint_items start_complete_sprint] } + + before do + create(:member, + project: source_project, + principal: user, + roles: [create(:project_role, permissions: %i[start_complete_sprint])]) + end + + it "hides Start sprint and Finish sprint" do + render_component + + expect(page).to have_no_selector(:menuitem, text: "Start sprint") + expect(page).to have_no_selector(:menuitem, text: "Finish sprint") + end + + it "does not show Task board for a board in the source project" do + create(:board_grid_with_query, project: source_project, linked: sprint) + + render_component + + expect(page).to have_no_selector(:menuitem, text: "Task board") + end + + it "shows Task board for a board in the rendered project" do + create(:board_grid_with_query, project:, linked: sprint) + + render_component + + expect(page).to have_selector(:menuitem, text: "Task board") + end + end end describe "always-visible items" do diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index 3445b87268c..731eeed91de 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -309,6 +309,20 @@ RSpec.describe RbSprintsController do end context "with the feature flag active", with_flag: { scrum_projects: true } do + context "when the sprint is rendered in a receiving project" do + let(:source_project) { create(:project, sprint_sharing: "share_all_projects") } + let(:project) { create(:project, sprint_sharing: "receive_shared") } + let!(:sprint) { create(:agile_sprint, project: source_project) } + + it "responds with not found and does not call the service", :aggregate_failures do + patch :start, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:not_found) + expect(service).not_to have_received(:call) + end + end + context "when a board already exists" do let!(:existing_board) do create(:board_grid_with_query, @@ -327,11 +341,11 @@ RSpec.describe RbSprintsController do context "when board creation succeeds" do let(:board) { create(:board_grid_with_query, project:, linked: sprint) } let(:service_result) do + started_sprint = sprint.tap { it.status = "active" } + allow(started_sprint).to receive(:task_board_for).with(project).and_return(board) + ServiceResult.success( - result: sprint.tap do |started_sprint| - started_sprint.status = "active" - started_sprint.task_board = board - end + result: started_sprint ) end @@ -429,6 +443,20 @@ RSpec.describe RbSprintsController do end context "with the feature flag active", with_flag: { scrum_projects: true } do + context "when the sprint is rendered in a receiving project" do + let(:source_project) { create(:project, sprint_sharing: "share_all_projects") } + let(:project) { create(:project, sprint_sharing: "receive_shared") } + let!(:sprint) { create(:agile_sprint, project: source_project, status: "active") } + + it "responds with not found and does not call the service", :aggregate_failures do + patch :finish, params: request_params + + expect(response).not_to be_successful + expect(response).to have_http_status(:not_found) + expect(service).not_to have_received(:call) + end + end + it "finishes the sprint and redirects to the backlog", :aggregate_failures do patch :finish, params: request_params diff --git a/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb b/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb index 7823d183760..9b4d4d0b4fe 100644 --- a/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_taskboards_controller_spec.rb @@ -52,6 +52,9 @@ RSpec.describe RbTaskboardsController do let(:sprint) { create(:agile_sprint, project:) } context "when the board exists" do + let!(:other_project) { create(:project) } + let!(:other_board) { create(:board_grid_with_query, project: other_project, linked: sprint) } + before do board.update!(linked: sprint) end @@ -66,6 +69,11 @@ RSpec.describe RbTaskboardsController do it "redirects to the board" do expect(response).to redirect_to(project_work_package_board_path(project, board)) end + + it "uses the board for the current project" do + expect(response).to redirect_to(project_work_package_board_path(project, board)) + expect(response).not_to redirect_to(project_work_package_board_path(other_project, other_board)) + end end end @@ -81,6 +89,26 @@ RSpec.describe RbTaskboardsController do end end + context "when the sprint is rendered in a receiving project" do + let(:source_project) { create(:project, sprint_sharing: "share_all_projects") } + let(:project) do + create(:project, + sprint_sharing: "receive_shared", + member_with_permissions: { user => permissions }) + end + let(:permissions) { %i[view_sprints view_work_packages] } + let(:sprint) { create(:agile_sprint, project: source_project) } + + before do + create(:board_grid_with_query, project: source_project, linked: sprint) + get :show, params: { project_id: project.identifier, sprint_id: sprint.id } + end + + it "returns not found when the receiving project has no task board" do + expect(response).to have_http_status(:not_found) + end + end + context "as a member without view_sprints permission" do let(:permissions) { [:view_project] } diff --git a/modules/backlogs/spec/features/sprints/start_finish_spec.rb b/modules/backlogs/spec/features/sprints/start_finish_spec.rb index a71ff63dda6..f8bd5462b1a 100644 --- a/modules/backlogs/spec/features/sprints/start_finish_spec.rb +++ b/modules/backlogs/spec/features/sprints/start_finish_spec.rb @@ -98,7 +98,7 @@ RSpec.describe "Start and finish sprints", expect_and_dismiss_flash type: :success, message: "The sprint was started." sprint = first_sprint.reload - board = sprint.task_board + board = sprint.task_board_for(project) board_page = Pages::Board.new(board) expect(page).to have_current_path(%r{/projects/#{project.identifier}/boards/\d+}) diff --git a/modules/backlogs/spec/models/agile/sprint_spec.rb b/modules/backlogs/spec/models/agile/sprint_spec.rb index a004b3a5b2c..bd2338f115b 100644 --- a/modules/backlogs/spec/models/agile/sprint_spec.rb +++ b/modules/backlogs/spec/models/agile/sprint_spec.rb @@ -116,6 +116,7 @@ RSpec.describe Agile::Sprint do describe "associations" do it { is_expected.to have_many(:work_packages).dependent(:nullify) } + it { is_expected.to have_many(:task_boards).dependent(:nullify) } it { is_expected.to belong_to(:project) } end @@ -125,8 +126,9 @@ RSpec.describe Agile::Sprint do end end - describe "#task_board" do + describe "#task_board_for" do let(:sprint) { create(:agile_sprint, project:) } + let(:other_project) { create(:project) } context "when a sprint task board exists" do let!(:board) do @@ -136,12 +138,15 @@ RSpec.describe Agile::Sprint do linked: sprint) end - it "returns the existing board" do - expect(sprint.task_board).to eq(board) + it "returns the existing board for the requested project" do + expect(sprint.task_board_for(project)).to eq(board) end - it "returns true for #task_board?" do - expect(sprint).to be_task_board + it "supports multiple task boards across projects" do + other_board = create(:board_grid_with_query, project: other_project, linked: sprint) + + expect(sprint.task_board_for(project)).to eq(board) + expect(sprint.task_board_for(other_project)).to eq(other_board) end end @@ -156,11 +161,15 @@ RSpec.describe Agile::Sprint do end it "returns nil" do - expect(sprint.task_board).to be_nil + expect(sprint.task_board_for(project)).to be_nil end + end - it "returns false for #task_board?" do - expect(sprint).not_to be_task_board + context "when only another project's board exists" do + let!(:other_board) { create(:board_grid_with_query, project: other_project, linked: sprint) } + + it "returns nil for the requested project" do + expect(sprint.task_board_for(project)).to be_nil end end end diff --git a/modules/backlogs/spec/services/sprints/start_service_spec.rb b/modules/backlogs/spec/services/sprints/start_service_spec.rb index 2bb9d73587c..641729aa8bc 100644 --- a/modules/backlogs/spec/services/sprints/start_service_spec.rb +++ b/modules/backlogs/spec/services/sprints/start_service_spec.rb @@ -54,7 +54,7 @@ RSpec.describe Sprints::StartService do it "creates the board and starts the sprint", :aggregate_failures do expect(result).to be_success expect(sprint.reload).to be_active - expect(sprint.task_board).to be_present + expect(sprint.task_board_for(project)).to be_present end end @@ -65,7 +65,21 @@ RSpec.describe Sprints::StartService do expect { result }.not_to change(Boards::Grid, :count) expect(result).to be_success expect(sprint.reload).to be_active - expect(sprint.task_board).to eq(existing_board) + expect(sprint.task_board_for(project)).to eq(existing_board) + end + end + + context "when a task board exists for another project" do + let!(:other_project) { create(:project) } + let!(:other_board) { create(:board_grid_with_query, project: other_project, linked: sprint) } + + it "creates a board for the sprint project", :aggregate_failures do + expect { result }.to change(Boards::Grid, :count).by(1) + expect(result).to be_success + expect(sprint.reload).to be_active + expect(sprint.task_board_for(project)).to be_present + expect(sprint.task_board_for(project)).not_to eq(other_board) + expect(sprint.task_board_for(other_project)).to eq(other_board) end end @@ -84,7 +98,7 @@ RSpec.describe Sprints::StartService do expect(result).not_to be_success expect(result.message).to eq("something went wrong") expect(sprint.reload).to be_in_planning - expect(sprint.task_board).to be_nil + expect(sprint.task_board_for(project)).to be_nil end end @@ -94,7 +108,7 @@ RSpec.describe Sprints::StartService do it "rolls back the created board", :aggregate_failures do expect(result).not_to be_success expect(sprint.reload).to be_in_planning - expect(sprint.task_board).to be_nil + expect(sprint.task_board_for(project)).to be_nil expect(result.message).to eq(sprint.errors.full_messages.to_sentence) end end @@ -111,7 +125,7 @@ RSpec.describe Sprints::StartService do expect(result.errors[:status]).to include("only one active sprint is allowed per project.") expect(result.message).to eq(sprint.errors.full_messages.to_sentence) expect(sprint.reload).to be_in_planning - expect(sprint.task_board).to be_nil + expect(sprint.task_board_for(project)).to be_nil end end @@ -122,7 +136,7 @@ RSpec.describe Sprints::StartService do expect(result).not_to be_success expect(result.message).to be_blank expect(sprint.reload).to be_active - expect(sprint.task_board).to be_nil + expect(sprint.task_board_for(project)).to be_nil end end @@ -133,7 +147,7 @@ RSpec.describe Sprints::StartService do expect(result).not_to be_success expect(result.message).to be_blank expect(sprint.reload).to be_completed - expect(sprint.task_board).to be_nil + expect(sprint.task_board_for(project)).to be_nil end end end diff --git a/modules/boards/app/models/boards/grid.rb b/modules/boards/app/models/boards/grid.rb index 92d14cde65b..55a0f04e405 100644 --- a/modules/boards/app/models/boards/grid.rb +++ b/modules/boards/app/models/boards/grid.rb @@ -31,7 +31,7 @@ module Boards class Grid < ::Grids::Grid belongs_to :project - belongs_to :linked, polymorphic: true, optional: true, inverse_of: :task_board + belongs_to :linked, polymorphic: true, optional: true, inverse_of: :task_boards validates :name, presence: true before_destroy :delete_queries, prepend: true diff --git a/modules/boards/spec/models/boards/grid_spec.rb b/modules/boards/spec/models/boards/grid_spec.rb index 3688cb8395b..aa1a56eb68f 100644 --- a/modules/boards/spec/models/boards/grid_spec.rb +++ b/modules/boards/spec/models/boards/grid_spec.rb @@ -34,9 +34,12 @@ RSpec.describe Boards::Grid do let(:instance) { described_class.new } let(:project) { build_stubbed(:project) } - describe "attributes" do - it { is_expected.to belong_to(:linked).optional } + describe "associations" do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:linked).inverse_of(:task_boards).optional } + end + describe "attributes" do it "#project" do instance.project = project expect(instance.project) From 66d8c8a3d42c9c626b4f4372c0e36c0492c1d79a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 18 Mar 2026 23:13:11 -0700 Subject: [PATCH 271/435] Add missing :aggregate_failures in controller spec --- .../spec/controllers/rb_sprints_controller_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index 731eeed91de..2b32f26b6f9 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -404,7 +404,7 @@ RSpec.describe RbSprintsController do context "without the 'start_complete_sprint' permission" do let(:permissions) { all_permissions - [:start_complete_sprint] } - it "responds with forbidden" do + it "responds with forbidden", :aggregate_failures do patch :start, params: request_params expect(response).not_to be_successful @@ -415,7 +415,7 @@ RSpec.describe RbSprintsController do context "when the sprint is already active" do let!(:sprint) { create(:agile_sprint, project:, status: "active") } - it "responds with not found" do + it "responds with not found", :aggregate_failures do patch :start, params: request_params expect(response).not_to be_successful @@ -494,7 +494,7 @@ RSpec.describe RbSprintsController do context "without the 'start_complete_sprint' permission" do let(:permissions) { all_permissions - [:start_complete_sprint] } - it "responds with forbidden" do + it "responds with forbidden", :aggregate_failures do patch :finish, params: request_params expect(response).not_to be_successful @@ -505,7 +505,7 @@ RSpec.describe RbSprintsController do context "when the sprint is already completed" do let!(:sprint) { create(:agile_sprint, project:, status: "completed") } - it "responds with not found" do + it "responds with not found", :aggregate_failures do patch :finish, params: request_params expect(response).not_to be_successful From 84e9512a4c759abc67eaf0118dab20f6701d104b Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 08:14:38 +0100 Subject: [PATCH 272/435] switch to raw EN strings in the editable identifier form spec --- .../projects/settings/editable_identifier_form_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/forms/projects/settings/editable_identifier_form_spec.rb b/spec/forms/projects/settings/editable_identifier_form_spec.rb index 45cdce0f11f..50195511607 100644 --- a/spec/forms/projects/settings/editable_identifier_form_spec.rb +++ b/spec/forms/projects/settings/editable_identifier_form_spec.rb @@ -43,7 +43,7 @@ RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do it "renders an editable field with the legacy caption" do expect(page).to have_field "Identifier", with: "my-project", disabled: false - expect(page).to have_text I18n.t("projects.settings.change_identifier_format_hint_legacy") + expect(page).to have_text "Only lowercase letters (a–z), numbers, dashes or underscores." end end @@ -56,7 +56,7 @@ RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do it "renders an editable field with the semantic caption" do expect(page).to have_field "Identifier", with: "my-project", disabled: false - expect(page).to have_text I18n.t("projects.settings.change_identifier_format_hint_semantic") + expect(page).to have_text "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. Must start with a letter." end end @@ -69,7 +69,7 @@ RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do it "renders an editable field with the legacy caption" do expect(page).to have_field "Identifier", with: "my-project", disabled: false - expect(page).to have_text I18n.t("projects.settings.change_identifier_format_hint_legacy") + expect(page).to have_text "Only lowercase letters (a–z), numbers, dashes or underscores." end end end From 10fec52da362bcb73d5732dce0237cb54b84c193 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 08:25:24 +0100 Subject: [PATCH 273/435] ditch a stray import --- frontend/src/stimulus/setup.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 5bd0cd329ff..9467ed961d4 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -25,7 +25,6 @@ import StemsController from './controllers/dynamic/work-packages/activities-tab/ import EditorController from './controllers/dynamic/work-packages/activities-tab/editor.controller'; import LazyPageController from './controllers/dynamic/work-packages/activities-tab/lazy-page.controller'; import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; -import IdentifierSuggestionController from './controllers/dynamic/projects/identifier-suggestion.controller'; import AutoSubmit from '@stimulus-components/auto-submit'; import RevealController from '@stimulus-components/reveal'; From 994aab92bd2e3f1c6955adc0f3742e4c38ff21c9 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 08:29:34 +0100 Subject: [PATCH 274/435] get rid of superfluous project specs --- spec/models/project_spec.rb | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 87f35f59b7b..068036d1af7 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -663,25 +663,10 @@ RSpec.describe Project do describe ".suggest_identifier" do context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do - it "returns initials for multi-word names" do - expect(described_class.suggest_identifier("Flight Planning Algorithm")).to eq("FPA") - end - - it "returns the first 3 characters for single-word names" do - expect(described_class.suggest_identifier("Banana")).to eq("BAN") - end - - it "returns an identifier starting with a letter even for digit-prefixed names" do - expect(described_class.suggest_identifier("3D Printing Lab")).to match(/\A[A-Z]/) - end - - it "transliterates accented characters" do - result = described_class.suggest_identifier("Équipe Réseau") - expect(result).to match(/\A[A-Z][A-Z0-9_]*\z/) - end - - it "falls back to PROJ for non-transliterable names" do - expect(described_class.suggest_identifier("日本語プロジェクト")).to eq("PROJ") + it "delegates to ProjectIdentifierSuggestionGenerator" do + expect(WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator) + .to receive(:suggest_identifier).with("My Project").and_return("MP") + expect(described_class.suggest_identifier("My Project")).to eq("MP") end end @@ -689,15 +674,6 @@ RSpec.describe Project do it "returns a slugified lowercase identifier" do expect(described_class.suggest_identifier("My Cool Project")).to eq("my-cool-project") end - - it "truncates to IDENTIFIER_MAX_LENGTH" do - long_name = "a" * 300 - expect(described_class.suggest_identifier(long_name).length).to be <= described_class::IDENTIFIER_MAX_LENGTH - end - - it "falls back to 'project' for blank names" do - expect(described_class.suggest_identifier("")).to eq("project") - end end end end From 618bf6660c5bafc003dce1dfd3c8cabeffe5ea4b Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 19 Mar 2026 08:35:26 +0100 Subject: [PATCH 275/435] Allow internal fork sync to also change workflows --- .github/workflows/sync-internal-fork.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sync-internal-fork.yml b/.github/workflows/sync-internal-fork.yml index 2d91bd10284..9d921e052c7 100644 --- a/.github/workflows/sync-internal-fork.yml +++ b/.github/workflows/sync-internal-fork.yml @@ -10,6 +10,9 @@ jobs: # Only run in the private fork, never in the public repo if: github.repository == 'opf/openproject-internal-fork' runs-on: ubuntu-latest + permissions: + contents: write + workflows: write steps: - name: Checkout repository From 3e66a9069228d4746942e2dd86dd0d0516e785a0 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 19 Mar 2026 08:39:46 +0100 Subject: [PATCH 276/435] Workflows permission cannot be given to a github token --- .github/workflows/sync-internal-fork.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/sync-internal-fork.yml b/.github/workflows/sync-internal-fork.yml index 9d921e052c7..0f2890aa6a5 100644 --- a/.github/workflows/sync-internal-fork.yml +++ b/.github/workflows/sync-internal-fork.yml @@ -12,7 +12,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - workflows: write steps: - name: Checkout repository From 9f5d251924f77398945854a3529aa2f4ded5100e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 19 Mar 2026 08:49:46 +0100 Subject: [PATCH 277/435] Use new token and also sync tags to internal fork --- .github/workflows/sync-internal-fork.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-internal-fork.yml b/.github/workflows/sync-internal-fork.yml index 0f2890aa6a5..454ce6f4ddc 100644 --- a/.github/workflows/sync-internal-fork.yml +++ b/.github/workflows/sync-internal-fork.yml @@ -10,15 +10,13 @@ jobs: # Only run in the private fork, never in the public repo if: github.repository == 'opf/openproject-internal-fork' runs-on: ubuntu-latest - permissions: - contents: write steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.OPENPROJECTCI_GH_FORK_TOKEN }} - name: Configure Git run: | @@ -49,3 +47,8 @@ jobs: git reset --hard "upstream/$branch" git push origin "$branch" --force done + + - name: Sync tags + run: | + git fetch upstream --tags + git push origin --tags --force From f00580401caa3d8bcb4db0e4bbabadecf5147b20 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 08:53:55 +0100 Subject: [PATCH 278/435] add the missing permission entry --- config/initializers/permissions.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 269ce381863..395b81f253c 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -32,7 +32,8 @@ Rails.application.reloader.to_prepare do OpenProject::AccessControl.map do |map| map.project_module nil, order: 100 do map.permission :add_project, - { projects: %i[new create] }, + { projects: %i[new create], + "projects/identifier_suggestions": %i[show] }, permissible_on: :global, require: :loggedin, contract_actions: { projects: %i[create] } From 42e84eb465d791c3e92253ef2a4816f6d74ffabb Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 09:03:23 +0100 Subject: [PATCH 279/435] fix identifier_suggestion test permissions --- spec/requests/projects/identifier_suggestions_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/projects/identifier_suggestions_spec.rb b/spec/requests/projects/identifier_suggestions_spec.rb index 9af753ee399..159f8d7d155 100644 --- a/spec/requests/projects/identifier_suggestions_spec.rb +++ b/spec/requests/projects/identifier_suggestions_spec.rb @@ -31,7 +31,7 @@ require "rails_helper" RSpec.describe "GET /projects/identifier_suggestion", type: :rails_request do - current_user { create(:user) } + current_user { create(:user, global_permissions: %i[add_project]) } context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do it "returns a suggested identifier derived from the name" do From cbddad915fa0179db6fb3b2f69aff1093cecf231 Mon Sep 17 00:00:00 2001 From: as-op Date: Thu, 19 Mar 2026 09:25:13 +0100 Subject: [PATCH 280/435] update internal contributor allowlist with Johanna and Fereshteh --- .github/workflows/cla.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index b1920ad916c..cb38df5e4ab 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -80,7 +80,9 @@ jobs: vspielau, wielinde, yanzubrytskyi, - ehassan01 + ehassan01, + JohannaStriebing, + fereshtehnm # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken remote-organization-name: opf From 4d10613b473576c7f6833bc03c8a7cf739721d69 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 09:33:46 +0100 Subject: [PATCH 281/435] add in the forgotten stimulus html attributes --- app/forms/projects/settings/editable_identifier_form.rb | 6 ++++-- app/forms/projects/settings/name_form.rb | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/forms/projects/settings/editable_identifier_form.rb b/app/forms/projects/settings/editable_identifier_form.rb index f929e1bd6f0..2ff8af4b19c 100644 --- a/app/forms/projects/settings/editable_identifier_form.rb +++ b/app/forms/projects/settings/editable_identifier_form.rb @@ -37,7 +37,8 @@ module Projects label: attribute_name(:identifier), caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"), required: true, - validation_message: validation_message_for(:identifier) + validation_message: validation_message_for(:identifier), + data: { "projects--identifier-suggestion-target": "identifier" } ) else f.text_field( @@ -45,7 +46,8 @@ module Projects label: attribute_name(:identifier), caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"), required: true, - validation_message: validation_message_for(:identifier) + validation_message: validation_message_for(:identifier), + data: { "projects--identifier-suggestion-target": "identifier" } ) end end diff --git a/app/forms/projects/settings/name_form.rb b/app/forms/projects/settings/name_form.rb index 21f20303af7..5e159be2e9e 100644 --- a/app/forms/projects/settings/name_form.rb +++ b/app/forms/projects/settings/name_form.rb @@ -31,7 +31,8 @@ module Projects module Settings class NameForm < ApplicationForm form do |f| - f.text_field name: :name, label: attribute_name(:name), required: true + f.text_field name: :name, label: attribute_name(:name), required: true, + data: { "projects--identifier-suggestion-target": "name" } end end end From c3dcf1c72298e75c600db00164d14aa2e33f6d7b Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 09:35:36 +0100 Subject: [PATCH 282/435] do not pass a plain string to the useDebounce wrapper --- .../projects/identifier-suggestion.controller.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts index 11f175b9d33..8c3ea979c1e 100644 --- a/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/projects/identifier-suggestion.controller.ts @@ -75,8 +75,7 @@ export default class extends ApplicationController { useDebounce(this, { wait: this.debounceValue }); this.nameTarget.addEventListener('blur', () => { - const name = this.nameTarget.value.trim(); - if (name) void this.fetchSuggestion(name); + void this.fetchSuggestion(); }, { signal }); } } @@ -101,8 +100,11 @@ export default class extends ApplicationController { } } - private async fetchSuggestion(name:string):Promise { - if (!this.urlValue || !this.hasIdentifierTarget) return; + private async fetchSuggestion():Promise { + if (!this.urlValue || !this.hasIdentifierTarget || !this.hasNameTarget) return; + + const name = this.nameTarget.value.trim(); + if (!name) return; this.identifierTarget.readOnly = true; this.identifierTarget.placeholder = I18n.t('js.projects.identifier_suggestion.loading'); From 3bf2f8b7f2eb3c6bc8651b9930178ddf06ee31a7 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 09:41:32 +0100 Subject: [PATCH 283/435] appease rubocop --- spec/forms/projects/settings/editable_identifier_form_spec.rb | 3 ++- spec/models/project_spec.rb | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/forms/projects/settings/editable_identifier_form_spec.rb b/spec/forms/projects/settings/editable_identifier_form_spec.rb index 50195511607..50ae1bd7901 100644 --- a/spec/forms/projects/settings/editable_identifier_form_spec.rb +++ b/spec/forms/projects/settings/editable_identifier_form_spec.rb @@ -56,7 +56,8 @@ RSpec.describe Projects::Settings::EditableIdentifierForm, type: :forms do it "renders an editable field with the semantic caption" do expect(page).to have_field "Identifier", with: "my-project", disabled: false - expect(page).to have_text "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. Must start with a letter." + expect(page).to have_text "Only uppercase letters (A–Z), numbers or underscores. " \ + "Max 10 characters. Must start with a letter." end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 068036d1af7..35cf147d91a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -664,9 +664,11 @@ RSpec.describe Project do describe ".suggest_identifier" do context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do it "delegates to ProjectIdentifierSuggestionGenerator" do - expect(WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator) + allow(WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator) .to receive(:suggest_identifier).with("My Project").and_return("MP") expect(described_class.suggest_identifier("My Project")).to eq("MP") + expect(WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator) + .to have_received(:suggest_identifier).with("My Project") end end From accc9a9d7d8f1c9a2493da881361dc483db26a72 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 10:53:56 +0100 Subject: [PATCH 284/435] fix: properly anchor the special character validation as it was matching a substring --- app/models/project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index 4614af3e4d9..6b229ba630f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -221,7 +221,7 @@ class Project < ApplicationRecord if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } validates :identifier, - format: { with: /[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, + format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH }, if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } From 1d7736124fb6bbea30c252f0d688b8fac1d8ef63 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 19 Mar 2026 11:58:37 +0100 Subject: [PATCH 285/435] adapt to removal of type limitation --- .../lib/open_project/backlogs/engine.rb | 15 ++-- ...work_package_representer_rendering_spec.rb | 75 +++++++++++++++---- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 146f423857b..901c8c68f73 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -226,20 +226,17 @@ module OpenProject::Backlogs config.to_prepare do enabled_backlogs_story = ->(type, project: nil) do if project.present? - project.backlogs_enabled? && type.story? + project.backlogs_enabled? && (OpenProject::FeatureDecisions.scrum_projects_active? || type.story?) else # Allow globally configuring the attribute if story - type.story? + OpenProject::FeatureDecisions.scrum_projects_active? || type.story? end end - story_and_sprint_permission = ->(type, project: nil) do - if project.present? - type.story? && User.current.allowed_in_project?(:view_sprints, project) - else - # Allow globally configuring the attribute if story - type.story? - end + story_and_sprint_permission = ->(_type, project: nil) do + return false unless OpenProject::FeatureDecisions.scrum_projects_active? + + project.nil? || User.current.allowed_in_project?(:view_sprints, project) end ::Type.add_constraint :position, enabled_backlogs_story diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb index e5a5ca5197c..602e20ff8c3 100644 --- a/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/work_package_representer_rendering_spec.rb @@ -32,7 +32,7 @@ require "spec_helper" # Only tests the links/properties added by the backlogs plugin. It does not retest the properties already # covered in the core. -RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_flag: { scrum_projects: true } do +RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering" do include API::V3::Utilities::PathHelper let(:work_package) do @@ -46,8 +46,9 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ let(:type) { story_type } let(:story_type) { build_stubbed(:type) } let(:task_type) { build_stubbed(:type) } + let(:enabled_module_names) { %w[backlogs] } let(:project) do - build_stubbed(:project, enabled_module_names: %w[backlogs]) + build_stubbed(:project, enabled_module_names:) end let(:story_points) { 23 } @@ -79,34 +80,76 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ describe "properties" do describe "storyPoints" do - context "when it is a story" do + context "when it is a story (without the feature flag on)", with_flag: { scrum_projects: false } do it_behaves_like "property", :storyPoints do let(:value) { story_points } end end - context "when it is a task" do + context "when it is a story (with the feature flag on)", with_flag: { scrum_projects: true } do + it_behaves_like "property", :storyPoints do + let(:value) { story_points } + end + end + + context "when it is a task (without the feature flag on)", with_flag: { scrum_projects: false } do let(:type) { task_type } it_behaves_like "no property", :storyPoints end + + context "when it is a task (with the feature flag on)", with_flag: { scrum_projects: true } do + let(:type) { task_type } + + it_behaves_like "property", :storyPoints do + let(:value) { story_points } + end + end + + context "when backlogs is disabled" do + let(:enabled_module_names) { [] } + + it_behaves_like "no property", :storyPoints + end end describe "position" do - it_behaves_like "property", :position do - let(:value) { position } + context "when it is a story (without the feature flag on)", with_flag: { scrum_projects: false } do + it_behaves_like "property", :position do + let(:value) { position } + end end - context "when it is a task" do + context "when it is a story (with the feature flag on)", with_flag: { scrum_projects: true } do + it_behaves_like "property", :position do + let(:value) { position } + end + end + + context "when it is a task (with the feature flag on)", with_flag: { scrum_projects: true } do let(:type) { task_type } + it_behaves_like "property", :position do + let(:value) { position } + end + end + + context "when it is a task (without the feature flag on)", with_flag: { scrum_projects: false } do + let(:type) { task_type } + + it_behaves_like "no property", :position + end + + context "when backlogs is disabled" do + let(:enabled_module_names) { [] } + it_behaves_like "no property", :position end end end describe "links" do - describe "sprint" do + describe "sprint", with_flag: { scrum_projects: true } do let(:link) { "sprint" } let(:href) { api_v3_paths.sprint(sprint.id) } let(:title) { sprint.name } @@ -115,6 +158,12 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ it_behaves_like "has a titled link" end + context "when it is a task" do + let(:type) { task_type } + + it_behaves_like "has a titled link" + end + context "when lacking the permission" do let(:permissions) { [] } @@ -124,12 +173,6 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ context "when the feature flag is inactive", with_flag: { scrum_projects: false } do it_behaves_like "has no link" end - - context "when it is a task" do - let(:type) { task_type } - - it_behaves_like "has no link" - end end describe "update links" do @@ -150,7 +193,7 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ end describe "embedded" do - describe "sprint" do + describe "sprint", with_flag: { scrum_projects: true } do let(:embedded_path) { "_embedded/sprint" } let(:embedded_resource) { sprint } let(:embedded_resource_type) { "Sprint" } @@ -172,7 +215,7 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter, "rendering", with_ context "when it is a type" do let(:type) { task_type } - it_behaves_like "has the resource not embedded" + it_behaves_like "has the resource embedded" end end end From bdeaee2abeeb79761fbabbd6da569dc54a2fd2e9 Mon Sep 17 00:00:00 2001 From: Jens Ulferts Date: Thu, 19 Mar 2026 12:03:36 +0100 Subject: [PATCH 286/435] fix spec description Co-authored-by: Dombi Attila <83396+dombesz@users.noreply.github.com> --- modules/backlogs/spec/features/backlogs/context_menu_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb index 72529a129f7..a9b685a4551 100644 --- a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb +++ b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb @@ -134,7 +134,7 @@ RSpec.describe "Backlogs context menu", :js do end end - context "when the user does not have manage_sprint_items permission" do + context "when the user does not have assign_versions permission" do before do RolePermission.where(permission: "assign_versions").delete_all end From a5fe5aa7db7cb5256cc2abdfa5d5055b8153953e Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 19 Mar 2026 12:08:29 +0100 Subject: [PATCH 287/435] use correct attribute check --- .../backlogs/patches/api/work_package_representer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb index 36adf0f38ce..99d904ba4da 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/api/work_package_representer.rb @@ -68,7 +68,7 @@ module OpenProject::Backlogs getter: ->(*) do if embed_links && represented.sprint.present? && - represented.type&.passes_attribute_constraint?(:story_points) && + represented.type&.passes_attribute_constraint?(:sprint) && current_user.allowed_in_project?(:view_sprints, represented.project) && OpenProject::FeatureDecisions.scrum_projects_active? ::API::V3::Sprints::SprintRepresenter.create(represented.sprint, current_user:) From 2e19fb154e2e8527881767de2072b8fde1ecd3a3 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 19 Mar 2026 12:08:45 +0100 Subject: [PATCH 288/435] fix spec descriptions --- .../spec/contracts/work_packages/shared_contract_examples.rb | 2 +- .../backlogs/spec/models/agile/sprints/scopes/visible_spec.rb | 2 +- spec/contracts/work_packages/update_contract_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb b/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb index 7634cfbaa48..6ab0a9fa0c1 100644 --- a/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb +++ b/modules/backlogs/spec/contracts/work_packages/shared_contract_examples.rb @@ -161,7 +161,7 @@ RSpec.shared_examples "work package contract with backlogs extensions" do # Otherwise, the production would need to have a superfluous check. let(:effective_permissions) { permissions - [:manage_sprint_items] } - it "includes non of the backlogs attributes", :aggregate_failures do + it "includes none of the backlogs attributes", :aggregate_failures do expect(contract.writable_attributes).not_to include("story_points", "sprint", "position") end end diff --git a/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb b/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb index 29ac3450c4e..ce5cc763895 100644 --- a/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb +++ b/modules/backlogs/spec/models/agile/sprints/scopes/visible_spec.rb @@ -71,7 +71,7 @@ RSpec.describe Agile::Sprints::Scopes::Visible do end end - context "for a user with view_sprnts in both projects" do + context "for a user with view_sprints in both projects" do current_user { user_with_permission_in_both } it "returns sprints from both projects" do diff --git a/spec/contracts/work_packages/update_contract_spec.rb b/spec/contracts/work_packages/update_contract_spec.rb index 4c2dd968ffc..a74c7cb88c5 100644 --- a/spec/contracts/work_packages/update_contract_spec.rb +++ b/spec/contracts/work_packages/update_contract_spec.rb @@ -455,7 +455,7 @@ RSpec.describe WorkPackages::UpdateContract do end describe ".add_comments_allowed?" do - context "with the user having manage_subtasks" do + context "with the user having add_work_package_comments" do let(:permissions) { [:add_work_package_comments] } it "is allowed" do From c6efb1ac5bbce55697ec8f3d5f57647c0f42b97f Mon Sep 17 00:00:00 2001 From: Johlan Pretorius Date: Thu, 19 Mar 2026 13:09:06 +0200 Subject: [PATCH 289/435] Refactor: Move actor to base class with optional keyword argument Move actor resolution to the caller and actor representation to RepresentedWebhookJob, keeping WorkPackageWebhookJob simple. The caller extracts the actor from the journal and passes it as an optional `actor:` keyword argument, making the feature available to all webhook types without changing existing method signatures. Co-Authored-By: Claude Opus 4.6 --- .../app/workers/represented_webhook_job.rb | 7 +++-- .../app/workers/work_package_webhook_job.rb | 15 --------- .../webhooks/event_resources/work_package.rb | 8 +++-- .../workers/work_package_webhook_job_spec.rb | 31 ++++++------------- 4 files changed, 20 insertions(+), 41 deletions(-) diff --git a/modules/webhooks/app/workers/represented_webhook_job.rb b/modules/webhooks/app/workers/represented_webhook_job.rb index e56e6aec12c..c42db5e0c01 100644 --- a/modules/webhooks/app/workers/represented_webhook_job.rb +++ b/modules/webhooks/app/workers/represented_webhook_job.rb @@ -31,8 +31,9 @@ class RepresentedWebhookJob < WebhookJob attr_reader :resource - def perform(webhook_id, resource, event_name) + def perform(webhook_id, resource, event_name, actor: nil) @resource = resource + @actor = actor super(webhook_id, event_name) return unless accepted_in_project? @@ -95,6 +96,8 @@ class RepresentedWebhookJob < WebhookJob end def actor_payload - nil + return nil unless @actor + + ::API::V3::Users::UserRepresenter.create(@actor, current_user: User.current) end end diff --git a/modules/webhooks/app/workers/work_package_webhook_job.rb b/modules/webhooks/app/workers/work_package_webhook_job.rb index 92312622479..04edb45a57e 100644 --- a/modules/webhooks/app/workers/work_package_webhook_job.rb +++ b/modules/webhooks/app/workers/work_package_webhook_job.rb @@ -29,14 +29,6 @@ #++ class WorkPackageWebhookJob < RepresentedWebhookJob - attr_reader :journal - - def perform(webhook_id, journal, event_name) - @journal = journal - @resource = journal.journable - super(webhook_id, @resource, event_name) - end - def payload_key :work_package end @@ -44,11 +36,4 @@ class WorkPackageWebhookJob < RepresentedWebhookJob def payload_representer_class ::API::V3::WorkPackages::WorkPackageRepresenter end - - def actor_payload - user = User.find_by(id: journal.user_id) - return nil unless user - - ::API::V3::Users::UserRepresenter.create(user, current_user: User.current) - end end diff --git a/modules/webhooks/lib/open_project/webhooks/event_resources/work_package.rb b/modules/webhooks/lib/open_project/webhooks/event_resources/work_package.rb index 802ce5f44a5..4e1132b93f4 100644 --- a/modules/webhooks/lib/open_project/webhooks/event_resources/work_package.rb +++ b/modules/webhooks/lib/open_project/webhooks/event_resources/work_package.rb @@ -50,11 +50,13 @@ module OpenProject::Webhooks::EventResources protected def handle_notification(payload, event_name) - action = payload[:journal].initial? ? "created" : "updated" + journal = payload[:journal] + action = journal.initial? ? "created" : "updated" event_name = prefixed_event_name(action) - work_package = payload[:journal].journable + work_package = journal.journable + actor = User.find_by(id: journal.user_id) active_webhooks.with_event_name(event_name).pluck(:id).each do |id| - WorkPackageWebhookJob.perform_later(id, work_package, event_name) + WorkPackageWebhookJob.perform_later(id, work_package, event_name, actor:) end end end diff --git a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb index 51179708a80..85269ab78ce 100644 --- a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb @@ -36,11 +36,11 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do shared_let(:request_url) { "http://example.net/test/42" } shared_let(:work_package) { create(:work_package, subject: title) } shared_let(:webhook) { create(:webhook, all_projects: true, url: request_url, secret: nil) } - shared_let(:journal) { work_package.journals.first } shared_examples "a work package webhook call" do let(:event) { "work_package:created" } - let(:job) { described_class.perform_now webhook.id, journal, event } + let(:actor) { nil } + let(:job) { described_class.perform_now webhook.id, work_package, event, actor: } let(:stubbed_url) { request_url } @@ -172,14 +172,15 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do let(:author) { create(:user, firstname: "Original", lastname: "Author") } let(:updater) { create(:user, firstname: "Update", lastname: "User") } let(:work_package) { create(:work_package, author:, subject: title) } - let(:journal) do + + before do work_package.add_journal(user: updater, notes: "Updated the work package") work_package.save! - work_package.journals.last end it_behaves_like "a work package webhook call" do let(:event) { "work_package:updated" } + let(:actor) { updater } it "includes actor matching the journal user, not the work package author" do subject @@ -203,10 +204,10 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do describe "actor field on created event" do let(:creator) { create(:user, firstname: "Creator", lastname: "Person") } let(:work_package) { User.execute_as(creator) { create(:work_package, author: creator, subject: title) } } - let(:journal) { work_package.journals.first } it_behaves_like "a work package webhook call" do let(:event) { "work_package:created" } + let(:actor) { creator } it "includes actor matching the creator" do subject @@ -221,18 +222,10 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do end end - describe "actor absent when journal user has been deleted" do - let(:updater) { create(:user) } - let(:journal) do - work_package.add_journal(user: updater, notes: "Updated") - work_package.save! - work_package.journals.last - end - - before { updater.destroy } - + describe "actor absent when actor is nil" do it_behaves_like "a work package webhook call" do let(:event) { "work_package:updated" } + let(:actor) { nil } it "fires the webhook without an actor key" do expect { subject }.not_to raise_error @@ -245,7 +238,7 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do end end - describe "admin custom field regression with journal (PR #16912)" do + describe "admin custom field regression with actor (PR #16912)" do shared_let(:project) { work_package.project } shared_let(:custom_field) do create(:project_custom_field, :string, admin_only: true, projects: [project]) @@ -257,14 +250,10 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do value: "wat") end let(:updater) { create(:admin) } - let(:journal) do - work_package.add_journal(user: updater, notes: "Updated") - work_package.save! - work_package.journals.last - end it_behaves_like "a work package webhook call" do let(:event) { "work_package:updated" } + let(:actor) { updater } it "includes the custom field value" do subject From abda9fff9d2429a28d2ad01efc45b98cf2f1b1d3 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 19 Mar 2026 12:34:26 +0100 Subject: [PATCH 290/435] adapt to removal of type limitation --- .../schema/work_package_schema_representer_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index 308dabf4340..c96cd523317 100644 --- a/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/modules/backlogs/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -77,7 +77,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "when not a story" do + context "when not a story", with_flag: { scrum_projects: false } do before do allow(schema.type).to receive(:story?).and_return(false) end @@ -107,7 +107,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "when not a story" do + context "when not a story", with_flag: { scrum_projects: false } do before do allow(schema.type).to receive(:story?).and_return(false) end @@ -159,7 +159,7 @@ RSpec.describe API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter, with end end - context "when not a story" do + context "when not a story", with_flag: { scrum_projects: false } do before do allow(schema.type).to receive(:story?).and_return(false) end From 46d79f6198964953b4e53ff1c577e052f9f07693 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 19 Mar 2026 12:37:01 +0100 Subject: [PATCH 291/435] Adapt widths after alignment with UX team --- frontend/src/global_styles/openproject/_mixins.sass | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass index 24975962cdd..35c929d6139 100644 --- a/frontend/src/global_styles/openproject/_mixins.sass +++ b/frontend/src/global_styles/openproject/_mixins.sass @@ -91,8 +91,7 @@ @mixin banner-styles position: absolute - max-width: 33% - min-width: 33% + max-width: 650px margin: 0 auto left: 0 right: 0 @@ -100,9 +99,8 @@ z-index: 10000 top: 4rem - @media screen and (max-width: $breakpoint-md) - max-width: 50% - min-width: 50% + @media screen and (max-width: $breakpoint-lg) + max-width: 550px @media screen and (max-width: $breakpoint-sm) max-width: calc(100% - 2rem) From c11b0dc6765e7e847819ffcf8f76bed97a764d0e Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Tue, 17 Mar 2026 16:30:34 +0100 Subject: [PATCH 292/435] [chore] add xwiki dev env - add first steps setup guide - add compose file and `.env` example --- docker/dev/xwiki/.env.example | 8 ++++++ docker/dev/xwiki/.gitignore | 2 ++ docker/dev/xwiki/README.md | 12 +++++++++ docker/dev/xwiki/docker-compose.yml | 41 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 docker/dev/xwiki/.env.example create mode 100644 docker/dev/xwiki/.gitignore create mode 100644 docker/dev/xwiki/README.md create mode 100644 docker/dev/xwiki/docker-compose.yml diff --git a/docker/dev/xwiki/.env.example b/docker/dev/xwiki/.env.example new file mode 100644 index 00000000000..40ba419cb86 --- /dev/null +++ b/docker/dev/xwiki/.env.example @@ -0,0 +1,8 @@ +# top-level domain override +OPENPROJECT_DOCKER_DEV_TLD=local + +# XWiki database values +XWIKI_DB_USER=xwiki +XWIKI_DB_PASSWORD=xwiki +XWIKI_DB_NAME=xwiki +XWIKI_DB_ROOT_PASSWORD=xwiki diff --git a/docker/dev/xwiki/.gitignore b/docker/dev/xwiki/.gitignore new file mode 100644 index 00000000000..b8865edfb36 --- /dev/null +++ b/docker/dev/xwiki/.gitignore @@ -0,0 +1,2 @@ +.env +docker-compose.override.yml diff --git a/docker/dev/xwiki/README.md b/docker/dev/xwiki/README.md new file mode 100644 index 00000000000..4935b6d3109 --- /dev/null +++ b/docker/dev/xwiki/README.md @@ -0,0 +1,12 @@ +# Setup guide + +A minimal setup guide for using a local XWiki inside a docker stack. The example compose file is connected to the +standard setup of the TLS-ready stack with `traefik`. + +## First steps + +- Up the docker stack with `docker compose --project-directory docker/dev/xwiki/ up -d` +- Go to https://xwiki.local +- Wait for initialisation to succeed +- Create admin user +- Select XWiki standard flavor and install it diff --git a/docker/dev/xwiki/docker-compose.yml b/docker/dev/xwiki/docker-compose.yml new file mode 100644 index 00000000000..2f852c113e3 --- /dev/null +++ b/docker/dev/xwiki/docker-compose.yml @@ -0,0 +1,41 @@ +services: + web: + image: xwiki:stable-postgres-tomcat + depends_on: + - db + environment: + - DB_USER=${XWIKI_DB_USER:-xwiki} + - DB_PASSWORD=${XWIKI_DB_PASSWORD:-xwiki} + - DB_HOST=db + labels: + - "traefik.enable=true" + - "traefik.http.routers.xwiki.rule=Host(`xwiki.${OPENPROJECT_DOCKER_DEV_TLD:-local}`)" + - "traefik.http.routers.xwiki.entrypoints=websecure" + volumes: + - xwiki-data:/usr/local/xwiki + networks: + - bridge + - gateway + db: + image: postgres:17 + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_ROOT_PASSWORD=${XWIKI_DB_ROOT_PASSWORD:-xwiki} + - POSTGRES_PASSWORD=${XWIKI_DB_PASSWORD:-xwiki} + - POSTGRES_USER=${XWIKI_DB_USER:-xwiki} + - POSTGRES_DB=${XWIKI_DB_NAME:-xwiki} + - POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale-provider=builtin --locale=C.UTF-8 + networks: + - bridge + +volumes: + postgres-data: + xwiki-data: + +networks: + bridge: + driver: bridge + gateway: + external: true + name: gateway From 46786b508595fac3976e63c5b730f68a1e723217 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Thu, 19 Mar 2026 14:45:02 +0100 Subject: [PATCH 293/435] Address minor style details after community contribution Those were too small for me to point out and thus block the corresponding PR further. But I wanted to address them anyways: * actor should be exposed the same way as resource for consistency * usage of actor can be simplified * one regression test didn't need repetition for actor case --- .../app/workers/represented_webhook_job.rb | 9 +++--- .../workers/work_package_webhook_job_spec.rb | 28 ------------------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/modules/webhooks/app/workers/represented_webhook_job.rb b/modules/webhooks/app/workers/represented_webhook_job.rb index 6ce165f1a34..a1b79bac47d 100644 --- a/modules/webhooks/app/workers/represented_webhook_job.rb +++ b/modules/webhooks/app/workers/represented_webhook_job.rb @@ -29,7 +29,7 @@ #++ class RepresentedWebhookJob < WebhookJob - attr_reader :resource + attr_reader :resource, :actor def perform(webhook_id, resource, event_name, actor: nil) @resource = resource @@ -89,15 +89,14 @@ class RepresentedWebhookJob < WebhookJob # have all the custom field visibility permissions set up correctly. User.system.run_given do payload = { action: event_name, payload_key => represented_payload } - actor = actor_payload - payload[:actor] = actor if actor + payload[:actor] = actor_payload if actor payload.to_json end end def actor_payload - return nil unless @actor + return nil unless actor - ::API::V3::Users::UserRepresenter.create(@actor, current_user: User.current) + ::API::V3::Users::UserRepresenter.create(actor, current_user: User.current) end end diff --git a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb index ba094f40055..90866e42691 100644 --- a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb @@ -239,32 +239,4 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do end end end - - describe "admin custom field regression with actor (PR #16912)" do - shared_let(:project) { work_package.project } - shared_let(:custom_field) do - create(:project_custom_field, :string, admin_only: true, projects: [project]) - end - shared_let(:custom_value) do - create(:custom_value, - custom_field:, - customized: project, - value: "wat") - end - let(:updater) { create(:admin) } - - it_behaves_like "a work package webhook call" do - let(:event) { "work_package:updated" } - let(:actor) { updater } - - it "includes the custom field value" do - subject - expect(stub).to have_been_requested - - log = Webhooks::Log.last - embedded_project = JSON.parse(log.request_body)["work_package"]["_embedded"]["project"] - expect(embedded_project[custom_field.attribute_name(:camel_case)]).to eq "wat" - end - end - end end From adb88c979b173c1cbc29eb29ea6da4180c283c71 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 18 Mar 2026 15:25:13 +0100 Subject: [PATCH 294/435] Some more test fixing.. There is light and the end of the tunnel :fingers_crossed: --- .../common/inplace_edit_field_component.rb | 32 ++-- .../boolean_input_component.rb | 5 +- .../date_input_component.rb | 4 +- .../calculated_value_input_component.rb | 2 +- .../display_fields/display_field_component.rb | 14 +- .../display_fields/select_list_component.rb | 16 +- .../rich_text_area_component.rb | 45 +++--- .../select_list_component.rb | 50 +++--- .../inplace_edit_fields_controller.rb | 92 +++++++---- .../attribute_help_texts_spec.rb | 6 +- .../overview_page/inputs_spec.rb | 9 +- .../overview_page/permission_spec.rb | 20 +-- .../overview_page/validation_spec.rb | 142 ++++++++--------- spec/models/custom_field_spec.rb | 12 +- .../components/common/inplace_edit_field.rb | 16 +- .../projects/project_custom_fields/dialog.rb | 145 ------------------ .../primerized/editor_form_field.rb | 2 +- 17 files changed, 270 insertions(+), 342 deletions(-) delete mode 100644 spec/support/components/projects/project_custom_fields/dialog.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index ec1f2818c44..95f16fdb033 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -84,21 +84,7 @@ module OpenProject def display_field_component return nil if display_field_class.nil? - @display_field_component ||= begin - has_comment = custom_field? && custom_field&.has_comment? - additional_args = open_in_dialog? ? dialog_display_arguments : {} - display_field_class.new( - model:, - attribute:, - writable: writable?, - truncated:, - has_comment:, - # Show comment as read-only text when a non-writable user opens the dialog. - # enforce_edit_mode identifies the dialog context. - show_comment: enforce_edit_mode && !writable? && has_comment, - **@system_arguments.merge(additional_args) - ) - end + @display_field_component ||= build_display_field_component end def wrapper_key @@ -163,6 +149,22 @@ module OpenProject private + def build_display_field_component + has_comment = custom_field? && custom_field&.has_comment? + additional_args = open_in_dialog? ? dialog_display_arguments : {} + display_field_class.new( + model:, + attribute:, + writable: writable?, + truncated:, + has_comment:, + # Show comment as read-only text when a non-writable user opens the dialog. + # enforce_edit_mode identifies the dialog context. + show_comment: enforce_edit_mode && !writable? && has_comment, + **@system_arguments.merge(additional_args) + ) + end + def dialog_trigger_arguments { dialog_controller_name: "inplace-edit", diff --git a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb index b4b0e896b2c..8026a6d7c60 100644 --- a/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/boolean_input_component.rb @@ -46,8 +46,8 @@ module OpenProject private - def submit_url - inplace_edit_field_submit_path( + def reset_url + inplace_edit_field_reset_path( model: model.class.name, id: model.id, attribute:, @@ -59,6 +59,7 @@ module OpenProject if show_action_buttons { data: { controller: "inplace-edit", + inplace_edit_url_value: reset_url, action: "click->inplace-edit#submitForm keydown.esc->inplace-edit#request", qa_field_name: } } diff --git a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb index 64de48b0458..360e08bd72e 100644 --- a/app/components/open_project/common/inplace_edit_fields/date_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/date_input_component.rb @@ -56,7 +56,9 @@ module OpenProject { data: { controller: "inplace-edit", inplace_edit_url_value: reset_url, - action: "keydown.esc->inplace-edit#request change->inplace-edit#submitForm", + action: "keydown.esc->inplace-edit#request " \ + "keydown.enter->inplace-edit#submitForm " \ + "change->inplace-edit#submitForm", qa_field_name: } } else diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb index 800321c7a16..85fe9f4e4c6 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb @@ -49,7 +49,7 @@ module OpenProject render(Primer::OpenProject::FlexLayout.new( align_items: :flex_start, - data: { test_selector: "error-cf-#{custom_field.id}" } + data: { test_selector: "error--custom_field_#{custom_field.id}" } )) do |container| container.with_column do render Primer::Beta::Octicon.new(icon: :"alert-fill", color: :danger) diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index c46ec9fbe8a..022fa37655d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -193,18 +193,22 @@ module OpenProject def dialog_controller_actions return "" unless writable? || @has_comment - "click->inplace-edit#openDialog " \ - "keydown.enter->inplace-edit#openDialog " \ - "keydown.space->inplace-edit#openDialog " \ + [ + "click->inplace-edit#openDialog", + "keydown.enter->inplace-edit#openDialog", + "keydown.space->inplace-edit#openDialog", "inplace-edit:open-dialog->async-dialog#handleOpenDialog" + ].join(" ") end def inline_controller_actions return "" unless writable? - "click->inplace-edit#request " \ - "keydown.enter->inplace-edit#request " \ + [ + "click->inplace-edit#request", + "keydown.enter->inplace-edit#request", "keydown.space->inplace-edit#request" + ].join(" ") end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb index a1aa1d34ad0..654b85499db 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -41,16 +41,22 @@ module OpenProject value = model.public_send(attribute) if value.present? && value != [nil] - if custom_field? - formatted_custom_field_values.presence || t("placeholders.default") - else - value.is_a?(Array) ? value.map(&:to_s).join(", ") : value.to_s - end + render_value(value) else t("placeholders.default") end end + private + + def render_value(value) + if custom_field? + formatted_custom_field_values.presence || t("placeholders.default") + else + value.is_a?(Array) ? value.join(", ") : value.to_s + end + end + def formatted_custom_field_values return @formatted_custom_field_values if defined?(@formatted_custom_field_values) diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index 2a3ba245e00..00de303150c 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -54,29 +54,36 @@ module OpenProject def call form.rich_text_area(name: attribute, - wrapper_data_attributes: { - controller: "ckeditor-focus", - ckeditor_focus_target: "editor", - ckeditor_focus_autofocus_value: true - }, + wrapper_data_attributes: ckeditor_wrapper_data, **@system_arguments) comment_field_if_enabled(form) + render_action_buttons if show_action_buttons + end - if show_action_buttons - form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| - button_group.submit(name: :reset, - type: :submit, - label: I18n.t(:button_cancel), - scheme: :default, - formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), - formmethod: :get, - test_selector: "op-inplace-edit-field--textarea-cancel") - button_group.submit(name: :submit, - label: I18n.t(:button_save), - scheme: :primary, - test_selector: "op-inplace-edit-field--textarea-save") - end + private + + def ckeditor_wrapper_data + { + controller: "ckeditor-focus", + ckeditor_focus_target: "editor", + ckeditor_focus_autofocus_value: true + } + end + + def render_action_buttons + form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| + button_group.submit(name: :reset, + type: :submit, + label: I18n.t(:button_cancel), + scheme: :default, + formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), + formmethod: :get, + test_selector: "op-inplace-edit-field--textarea-cancel") + button_group.submit(name: :submit, + label: I18n.t(:button_save), + scheme: :primary, + test_selector: "op-inplace-edit-field--textarea-save") end end end diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index 3aa4524d0e2..7669d8b3be8 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -40,17 +40,7 @@ module OpenProject super @system_arguments[:autocomplete_options] ||= {} - @system_arguments[:autocomplete_options][:model] ||= { id: model.id, name: model.name } - @system_arguments[:autocomplete_options][:inputName] ||= attribute - @system_arguments[:autocomplete_options][:wrapper_id] ||= @system_arguments[:wrapper_id] - if @system_arguments[:autocomplete_options][:focusDirectly].nil? - @system_arguments[:autocomplete_options][:focusDirectly] = - true - end - if @system_arguments[:autocomplete_options][:closeOnSelect].nil? - @system_arguments[:autocomplete_options][:closeOnSelect] = - false - end + set_autocomplete_defaults(model, attribute) end def call @@ -61,24 +51,34 @@ module OpenProject end comment_field_if_enabled(form) - - if show_action_buttons - form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| - button_group.submit(name: :reset, - type: :submit, - label: I18n.t(:button_cancel), - scheme: :default, - formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), - formmethod: :get) - button_group.submit(name: :submit, - label: I18n.t(:button_save), - scheme: :primary) - end - end + render_action_buttons if show_action_buttons end private + def set_autocomplete_defaults(model, attribute) + opts = @system_arguments[:autocomplete_options] + opts[:model] ||= { id: model.id, name: model.name } + opts[:inputName] ||= attribute + opts[:wrapper_id] ||= @system_arguments[:wrapper_id] + opts[:focusDirectly] = true if opts[:focusDirectly].nil? + opts[:closeOnSelect] = false if opts[:closeOnSelect].nil? + end + + def render_action_buttons + form.group(layout: :horizontal, justify_content: :flex_end) do |button_group| + button_group.submit(name: :reset, + type: :submit, + label: I18n.t(:button_cancel), + scheme: :default, + formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:), + formmethod: :get) + button_group.submit(name: :submit, + label: I18n.t(:button_save), + scheme: :primary) + end + end + def render_custom_field_input input_class = if custom_field.multi_value? CustomFields::Inputs::MultiSelectList diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index d5ec4c49f13..dc9c64904db 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -45,17 +45,7 @@ class InplaceEditFieldsController < ApplicationController end def update - handler = update_registry.fetch_handler(@model) - - if handler.present? - success = handler.call( - model: @model, - params: permitted_params, - user: current_user - ) - else - raise ArgumentError, "Missing update handler for #{@model}" - end + success = invoke_update_handler if success render_success_flash_message_via_turbo_stream( @@ -65,10 +55,17 @@ class InplaceEditFieldsController < ApplicationController refresh_calculated_dependents end - replace_via_turbo_stream( - component: component(enforce_edit_mode: !success), - status: success ? :ok : :unprocessable_entity - ) + if !success && dialog_id + replace_via_turbo_stream( + component: dialog_field_component, + status: :unprocessable_entity + ) + else + replace_via_turbo_stream( + component: component(enforce_edit_mode: !success), + status: success ? :ok : :unprocessable_entity + ) + end respond_with_turbo_streams rescue ArgumentError @@ -92,6 +89,13 @@ class InplaceEditFieldsController < ApplicationController private + def invoke_update_handler + handler = update_registry.fetch_handler(@model) + raise ArgumentError, "Missing update handler for #{@model}" if handler.blank? + + handler.call(model: @model, params: permitted_params, user: current_user) + end + def find_model model_class = resolve_model_class(params[:model]) @model = model_class.visible.find(params[:id]) @@ -158,19 +162,18 @@ class InplaceEditFieldsController < ApplicationController cf_values = params.dig(model_key, :custom_field_values) raw_value = cf_values.is_a?(Array) ? cf_values : cf_values&.dig(custom_field_id) - # Handle both single-select and multi-select - processed_value = if raw_value.is_a?(Array) - # Remove empty strings from the hidden field, then extract the actual value. - # FilterableTreeView encodes each selected item as a JSON payload - # {"path":[...],"value":""} — extract only the "value" field. - cleaned_values = raw_value.compact_blank.filter_map { |v| extract_tree_view_value(v) } - # For single-select, unwrap the array to get the single value - cleaned_values.size <= 1 ? cleaned_values.first : cleaned_values - else - raw_value - end + { @attribute => process_cf_raw_value(raw_value) } + end - { @attribute => processed_value } + def process_cf_raw_value(raw_value) + return raw_value unless raw_value.is_a?(Array) + + # Remove empty strings from the hidden field, then extract the actual value. + # FilterableTreeView encodes each selected item as a JSON payload + # {"path":[...],"value":""} — extract only the "value" field. + cleaned_values = raw_value.compact_blank.filter_map { |v| extract_tree_view_value(v) } + # For single-select, unwrap the array to get the single value + cleaned_values.size <= 1 ? cleaned_values.first : cleaned_values end def extract_tree_view_value(raw) @@ -198,6 +201,25 @@ class InplaceEditFieldsController < ApplicationController ) end + # Builds the edit-mode component targeting the field *inside* the dialog. + # Used when an update fails while submitting from a dialog: the error state + # should be shown within the dialog, not at the page trigger location. + # Keeps the dialog field's own :id (not page_component_id) so the Turbo + # Stream targets the correct wrapper inside the dialog, and preserves + # :wrapper_id / :form_id so the re-rendered form still submits via the dialog. + def dialog_field_component + args = system_arguments.to_h.symbolize_keys + + OpenProject::Common::InplaceEditFieldComponent.new( + model: @model, + attribute: @attribute, + enforce_edit_mode: true, + show_action_buttons: false, + update_registry:, + **args + ) + end + def dialog_id wrapper_id = system_arguments.to_h["wrapper_id"] wrapper_id&.delete_prefix("#") @@ -244,14 +266,16 @@ class InplaceEditFieldsController < ApplicationController end def stable_key_system_arguments - @stable_key_system_arguments ||= begin - raw = params[:stable_key_system_arguments] - return {} if raw.blank? + @stable_key_system_arguments ||= parse_stable_key_system_arguments + end - JSON.parse(raw) - rescue JSON::ParserError - {} - end + def parse_stable_key_system_arguments + raw = params[:stable_key_system_arguments] + return {} if raw.blank? + + JSON.parse(raw) + rescue JSON::ParserError + {} end def update_registry diff --git a/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb b/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb index 97b02108484..b5793f26d75 100644 --- a/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb @@ -64,7 +64,11 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute it "shows field labels with help text link" do input_fields.each do |custom_field| - field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field = if custom_field == text_project_custom_field + overview_page.open_modal_for_custom_field(custom_field) + else + overview_page.open_inplace_edit_field_for_custom_field(custom_field) + end field.expect_field_label_with_help_text custom_field.name field.close end diff --git a/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb index 890b55eafb6..b9be58ef719 100644 --- a/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/inputs_spec.rb @@ -50,6 +50,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do shared_examples "shows comment input only when comments are allowed by custom field" do it "shows comment input only when comments are allowed by custom field" do custom_field.update!(has_comment: true) + refresh dialog.within_async_content(close_after_yield: true) do expect(page).to have_field("Comment", with: "") @@ -158,7 +159,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do shared_examples "a rich text custom field input" do it "shows the correct value if given" do dialog.within_async_content(close_after_yield: true) do - field.expect_value(expected_initial_value) + form_field.expect_value(expected_initial_value) end end @@ -166,7 +167,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.custom_values.destroy_all dialog.within_async_content(close_after_yield: true) do - field.expect_value(expected_blank_value) + form_field.expect_value(expected_blank_value) end end @@ -175,7 +176,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.update!(default_value:) dialog.within_async_content(close_after_yield: true) do - field.expect_value(default_value) + form_field.expect_value(default_value) end end @@ -244,7 +245,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do let(:default_value) { "https://openproject.org" } let(:expected_blank_value) { "" } let(:expected_initial_value) { "https://www.openproject.org" } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:form_field) { FormFields::Primerized::InputField.new(custom_field) } it_behaves_like "a custom field input" diff --git a/spec/features/projects/project_custom_fields/overview_page/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/permission_spec.rb index 311f7b92406..21b82b144b8 100644 --- a/spec/features/projects/project_custom_fields/overview_page/permission_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/permission_spec.rb @@ -140,7 +140,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "shows the modal buttons" do overview_page.within_project_attributes_sidebar do - expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 13) + expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 1) end end @@ -156,23 +156,23 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end describe "with insufficient Edit attribute permission on the update dialog" do - let(:member) { member_with_project_attributes_edit_permissions } + let(:member) { member_in_project } let(:custom_field) { boolean_project_custom_field } - let(:dialog) { Components::Projects::ProjectCustomFields::Dialog.new(project, custom_field) } + let(:enable_comments) { true } before do login_as member overview_page.visit_page end - it "responds with a permission denied message" do - overview_page.open_modal_for_custom_field(custom_field) - # Change role to project edit, so the user won't have the project attributes edit role - member_with_project_attributes_edit_permissions.memberships.first.update(roles: [edit_project_role]) - member_with_project_attributes_edit_permissions.reload - dialog.submit + it "opens the dialog in readonly mode" do + dialog = overview_page.open_modal_for_custom_field(custom_field) - expect_flash(type: :error, message: I18n.t(:notice_not_authorized)) + dialog.expect_open + + dialog.within_dialog do + expect(page).not_to have_test_selector("op-inplace-edit-field--form") + end end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/validation_spec.rb index 49104aba9dc..1440b0b131e 100644 --- a/spec/features/projects/project_custom_fields/overview_page/validation_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/validation_spec.rb @@ -46,22 +46,22 @@ RSpec.describe "Edit project custom fields on project overview page", :js 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) + form_field = FormFields::Primerized::InputField.new(custom_field) - dialog = overview_page.open_modal_for_custom_field(custom_field) + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) - dialog.within_async_content do + field.within_field do expect(page).to have_text("String 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 + form_field.fill_in(with: "") # this will trigger the validation - dialog.submit + field.submit - field.expect_error(I18n.t("activerecord.errors.messages.blank")) + form_field.expect_error(I18n.t("activerecord.errors.messages.blank")) - dialog.within_async_content do + field.within_field do expect(page).to have_text("String field") expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) end @@ -71,9 +71,10 @@ RSpec.describe "Edit project custom fields on project overview page", :js do shared_examples "keeps the unpersisted values" do it "keeps the value" do invalid_custom_field.update!(is_required: true) - dialog = overview_page.open_modal_for_custom_field(invalid_custom_field) + refresh + field = overview_page.open_inplace_edit_field_for_custom_field(invalid_custom_field) invalid_field.clear - dialog.submit + field.submit invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) invalid_field.expect_blank @@ -81,6 +82,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "keeps the custom comment value" do invalid_custom_field.update!(is_required: true, has_comment: true) + refresh dialog = overview_page.open_modal_for_custom_field(invalid_custom_field) invalid_field.clear fill_in "Comment", with: "A helpful comment" @@ -147,25 +149,19 @@ RSpec.describe "Edit project custom fields on project overview page", :js do end describe "editing multiple fields" do - let(:input_fields_dialog) do - Components::Projects::ProjectCustomFields::Dialog.new(project, section_for_input_fields) - end - let(:select_fields_dialog) do - Components::Projects::ProjectCustomFields::Dialog.new(project, section_for_select_fields) - end - let(:field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) } + let(:form_field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) } it "displays validation errors, when the previous modal was canceled (Regression)" do list_project_custom_field.update!(is_required: true) list_project_custom_field.custom_values.destroy_all - dialog = overview_page.open_modal_for_custom_field(string_project_custom_field) - dialog.close + field = overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field) + field.close - dialog = overview_page.open_modal_for_custom_field(list_project_custom_field) - dialog.submit + field = overview_page.open_inplace_edit_field_for_custom_field(list_project_custom_field) + field.submit - field.expect_error(I18n.t("activerecord.errors.messages.blank")) + form_field.expect_error(I18n.t("activerecord.errors.messages.blank")) end context "with required custom fields in different sections" do @@ -185,44 +181,44 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "validates required fields only within their respective sections" do # Test 1: Multi-select field can be saved even when other required fields are invalid - multi_list_field_dialog = - overview_page.open_modal_for_custom_field(multi_list_project_custom_field) + multi_list_inplace_field = + overview_page.open_inplace_edit_field_for_custom_field(multi_list_project_custom_field) - multi_list_field_dialog.submit - multi_list_field_dialog.expect_closed + multi_list_inplace_field.submit + multi_list_inplace_field.expect_close # Test 2: Edit the required string field - string_field_dialog = - overview_page.open_modal_for_custom_field(string_project_custom_field) + string_field_inplace_field = + overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field) # Submit without filling - should show error - string_field_dialog.submit + string_field_inplace_field.submit string_field.expect_error(I18n.t("activerecord.errors.messages.blank")) - string_field_dialog.close + string_field_inplace_field.close # Test 3: Edit the required list field - list_field_dialog = - overview_page.open_modal_for_custom_field(list_project_custom_field) + list_field_inplace_field = + overview_page.open_inplace_edit_field_for_custom_field(list_project_custom_field) # Submit without filling - should show error - list_field_dialog.submit + list_field_inplace_field.submit list_field.expect_error(I18n.t("activerecord.errors.messages.blank")) # Test 4: Fill required field and submit successfully list_field.select_option("Option 1") - list_field_dialog.submit - list_field_dialog.expect_closed + list_field_inplace_field.submit + list_field_inplace_field.expect_close # Test 5: The required string field dialog still fails validation when empty - string_field_dialog = - overview_page.open_modal_for_custom_field(string_project_custom_field) - string_field_dialog.submit + string_field_inplace_field = + overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field) + string_field_inplace_field.submit string_field.expect_error(I18n.t("activerecord.errors.messages.blank")) # Test 6: Complete the required string field and expect to pass validation string_field.fill_in(with: "Test value") - string_field_dialog.submit - string_field_dialog.expect_closed + string_field_inplace_field.submit + string_field_inplace_field.expect_close end end end @@ -233,11 +229,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.update!(is_required: true) custom_field.custom_values.destroy_all - dialog = overview_page.open_modal_for_custom_field(custom_field) + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) - dialog.submit + field.submit - field.expect_error(I18n.t("activerecord.errors.messages.blank")) + form_field.expect_error(I18n.t("activerecord.errors.messages.blank")) end end @@ -245,37 +241,51 @@ RSpec.describe "Edit project custom fields on project overview page", :js do describe "with string CF" do let(:custom_field) { string_project_custom_field } - let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:form_field) { FormFields::Primerized::InputField.new(custom_field) } 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(:form_field) { FormFields::Primerized::InputField.new(custom_field) } 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(:form_field) { FormFields::Primerized::InputField.new(custom_field) } 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(:form_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) } + let(:form_field) do + FormFields::Primerized::EditorFormField.new( + custom_field, + selector: "[data-test-selector='augmented-text-area-custom_field_#{custom_field.id}']" + ) + end - it_behaves_like "a custom field input" + it "shows an error if the value is invalid" do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + dialog = overview_page.open_modal_for_custom_field(custom_field).dialog + + dialog.submit + + form_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + end end describe "with calculated value CFs" do @@ -283,17 +293,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do it "allows saving the dialog even if the calculated custom field is invalid" do custom_field.custom_values.destroy_all - dialog = overview_page.open_modal_for_custom_field(custom_field) + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) - dialog.submit + field.submit - dialog.expect_closed - end - - it "displays the custom field label without the required asterisk" do - expect(page).to have_css("span", text: calculated_field.name) do |label| - expect(label).to have_no_css("span", text: "*") - end + field.expect_close end end @@ -314,7 +318,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do before do # prevent calculation from happening calculated_from_int_and_float_project_custom_field.custom_values.delete_all - calculated_from_int_and_float_project_custom_field.update(is_required: true) + calculated_from_int_and_float_project_custom_field.update!(is_required: true) end let(:custom_field) { integer_project_custom_field } @@ -331,31 +335,31 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.update!(is_required: true) custom_field.custom_values.destroy_all - dialog = overview_page.open_modal_for_custom_field(custom_field) + dialog = overview_page.open_inplace_edit_field_for_custom_field(custom_field) dialog.submit - field.expect_error(I18n.t("activerecord.errors.messages.blank")) + form_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) } + let(:form_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) } + let(:form_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) } + let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } it_behaves_like "a custom field select" end @@ -367,31 +371,31 @@ RSpec.describe "Edit project custom fields on project overview page", :js do custom_field.update!(is_required: true) custom_field.custom_values.destroy_all - dialog = overview_page.open_modal_for_custom_field(custom_field) + field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) - dialog.submit + field.submit - field.expect_error(I18n.t("activerecord.errors.messages.blank")) + form_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) } + let(:form_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) } + let(:form_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) } + let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) } it_behaves_like "a custom field multi select" end diff --git a/spec/models/custom_field_spec.rb b/spec/models/custom_field_spec.rb index 394af5d6e6e..3e64255a8b6 100644 --- a/spec/models/custom_field_spec.rb +++ b/spec/models/custom_field_spec.rb @@ -747,19 +747,23 @@ RSpec.describe CustomField do it "registers the custom field in the inplace edit field registry" do custom_field = build(:custom_field, field_format: "string") - expect(OpenProject::InplaceEdit::FieldRegistry) - .to receive(:register_custom_field) - .with(anything, "string") + allow(OpenProject::InplaceEdit::FieldRegistry).to receive(:register_custom_field) custom_field.save! + + expect(OpenProject::InplaceEdit::FieldRegistry) + .to have_received(:register_custom_field) + .with(anything, "string") end it "does not re-register when updated" do custom_field = create(:custom_field, field_format: "string") - expect(OpenProject::InplaceEdit::FieldRegistry).not_to receive(:register_custom_field) + allow(OpenProject::InplaceEdit::FieldRegistry).to receive(:register_custom_field) custom_field.update!(name: "Updated name") + + expect(OpenProject::InplaceEdit::FieldRegistry).not_to have_received(:register_custom_field) end end end diff --git a/spec/support/components/common/inplace_edit_field.rb b/spec/support/components/common/inplace_edit_field.rb index 169e8a1d3a9..221423a78bc 100644 --- a/spec/support/components/common/inplace_edit_field.rb +++ b/spec/support/components/common/inplace_edit_field.rb @@ -54,8 +54,9 @@ module Components within_field do # Link and user type custom fields might contain a clickable link inside the edit container. # Use JavaScript to directly trigger the click event on the container to avoid nested links. + selector = "op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}" page.execute_script( - "document.querySelector('[data-test-selector=\"op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}\"] .op-inplace-edit--display-field').click()" + "document.querySelector('[data-test-selector=\"#{selector}\"] .op-inplace-edit--display-field').click()" ) end end @@ -95,6 +96,19 @@ module Components link.click end + def expect_error(string) + within_field do + expect(page).to have_css(".FormControl-inlineValidation", text: string) + end + end + + def expect_calculation_error(string) + within_field do + expect(page).to have_test_selector("error--#{attribute.name}") + expect(page).to have_content(string) + end + end + def fill_and_submit_value(name:, val:, ckeditor: false) if ckeditor find(".ck-content").base.send_keys val diff --git a/spec/support/components/projects/project_custom_fields/dialog.rb b/spec/support/components/projects/project_custom_fields/dialog.rb deleted file mode 100644 index ff3c39c0ebe..00000000000 --- a/spec/support/components/projects/project_custom_fields/dialog.rb +++ /dev/null @@ -1,145 +0,0 @@ -# frozen_string_literal: true - -# -- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -# ++ - -require "support/components/common/modal" -require "support/components/autocompleter/ng_select_autocomplete_helpers" - -module Components - module Projects - module ProjectCustomFields - class Dialog < Components::Common::Modal - include Components::Autocompleter::NgSelectAutocompleteHelpers - - attr_reader :project, :project_custom_field, :title - - def initialize(project, project_custom_field) - super() - - @project = project - @project_custom_field = project_custom_field - @title = @project_custom_field.name - end - - def dialog_css_selector - "dialog#project-custom-field-dialog-#{@project_custom_field.id}" - end - - def async_content_container_css_selector - "#{dialog_css_selector} [data-test-selector='async-dialog-content']" - end - - def within_dialog(close_after_yield: false, &) - within(dialog_css_selector, &).tap do - close if close_after_yield - end - end - - def within_async_content(close_after_yield: false, &) - within(async_content_container_css_selector, &).tap do - close if close_after_yield - end - 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 submit - within(dialog_css_selector) do - page.find("[data-test-selector='save-project-attributes-button']").click - 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 expect_field_label_with_help_text(label_text) - expect_field_label(label_text) - expect(find_field_label(label_text)).to have_link accessible_name: "Show help text" - end - - def expect_field_label_without_help_text(label_text) - expect_field_label(label_text) - expect(find_field_label(label_text)).to have_no_link accessible_name: "Show help text" - end - - def click_help_text_link_for_label(label_text) - link = find_field_label(label_text).find(:link, accessible_name: "Show help text") - link.click - end - - def expect_field_label(label_text) - within_dialog do - expect(page).to have_element :label, text: label_text - end - end - - def find_field_label(label_text) - within_dialog do - page.find(:element, :label, text: label_text) - end - end - - ### - - def input_containers - within "#project-custom-field-edit-form > .FormControl-spacingWrapper" do - page.all(".FormControl-spacingWrapper") - end - 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-test-selector='project-custom-field-input-container-#{custom_field.id}']", &) - end - end - end - end - end -end diff --git a/spec/support/form_fields/primerized/editor_form_field.rb b/spec/support/form_fields/primerized/editor_form_field.rb index be6ab505188..9918e86adca 100644 --- a/spec/support/form_fields/primerized/editor_form_field.rb +++ b/spec/support/form_fields/primerized/editor_form_field.rb @@ -16,7 +16,7 @@ module FormFields end def field_container - augmented_textarea = page.find("[data-text-area-id='\"project_custom_field_values_#{property.id}\"']") + augmented_textarea = page.find(selector || "[data-text-area-id='\"project_custom_field_values_#{property.id}\"']") augmented_textarea.first(:xpath, ".//..") end From 0b26ae64b919b1d06a1ec6e5b77dc20af49fd97a Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Thu, 19 Mar 2026 15:45:47 +0100 Subject: [PATCH 295/435] Update httpx Fixing a bug that was reported to OpenProject explicitly. --- Gemfile | 4 +--- Gemfile.lock | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 3510e503983..9e8201be01d 100644 --- a/Gemfile +++ b/Gemfile @@ -254,9 +254,7 @@ gem "factory_bot_rails", "~> 6.5.0", require: false gem "turbo_power", "~> 0.7.0" gem "turbo-rails", "~> 2.0.20" -# There is a problem with version 1.4.0. Do not update until you're sure there is no infinite hang -# happenning in failing tests when WebMock or VCR stub cannot be found. -gem "httpx", "~> 1.7.3" +gem "httpx", "~> 1.7.4" # Brings actual deep-freezing to most ruby objects gem "ice_nine" diff --git a/Gemfile.lock b/Gemfile.lock index c18d8ae1915..32f75f4ffac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -703,7 +703,7 @@ GEM htmlentities (4.3.4) http-2 (1.1.3) http_parser.rb (0.8.1) - httpx (1.7.3) + httpx (1.7.4) http-2 (>= 1.1.3) i18n (1.14.8) concurrent-ruby (~> 1.0) @@ -1636,7 +1636,7 @@ DEPENDENCIES grids! html-pipeline (~> 2.14.0) htmldiff - httpx (~> 1.7.3) + httpx (~> 1.7.4) i18n-js (~> 4.2.4) i18n-tasks (~> 1.1.0) ice_cube (~> 0.17.0) @@ -1975,7 +1975,7 @@ CHECKSUMS htmlentities (4.3.4) sha256=125a73c6c9f2d1b62100b7c3c401e3624441b663762afa7fe428476435a673da http-2 (1.1.3) sha256=1b2f379d35a11dbae94f8a1a52c053d8c161eb4a0c98b5d1605ff1b2bf171c9c http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a - httpx (1.7.3) sha256=126914109a58350e5ad0c13786092f35e2419857dad15745f31fc81c371f93a8 + httpx (1.7.4) sha256=91fb3e0f7325966a5da4d463a1dd7240e8550d8b0de79e346cc5dc1df1eacd2b i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 i18n-js (4.2.4) sha256=61390d372f8fa68c495c5907d577657e8cc3a7031f4945db1e91f935e1391355 i18n-tasks (1.1.2) sha256=4dcfba49e52a623f30661cb316cb80d84fbba5cb8c6d88ef5e02545fffa3637a From 7e93d9f69ac791c23bc729786ab3a616b99f6286 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Thu, 19 Mar 2026 17:10:45 +0100 Subject: [PATCH 296/435] Update supported Nextcloud Integration app version (#22402) Update versions & README --- .../system-requirements/README.md | 5 +++-- modules/storages/config/nextcloud_dependencies.yml | 2 +- .../providers/nextcloud/queries/capabilities_query_spec.rb | 6 +++--- .../vcr_cassettes/nextcloud/capabilities_success.yml | 2 +- .../capabilities_success_team_folders_disabled.yml | 2 +- .../capabilities_success_team_folders_not_installed.yml | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/installation-and-operations/system-requirements/README.md b/docs/installation-and-operations/system-requirements/README.md index a765d030626..2b93c02b922 100644 --- a/docs/installation-and-operations/system-requirements/README.md +++ b/docs/installation-and-operations/system-requirements/README.md @@ -212,9 +212,9 @@ OpenProject supports the latest versions of the major browsers. #### Nextcloud Server -* [Nextcloud 30](https://nextcloud.com/changelog/#latest30) * [Nextcloud 31](https://nextcloud.com/changelog/#latest31) * [Nextcloud 32](https://nextcloud.com/changelog/#latest32) +* [Nextcloud 33](https://nextcloud.com/changelog/#latest33) > [!TIP] > @@ -229,7 +229,8 @@ OpenProject supports the latest versions of the major browsers. ##### OpenProject integration -* [OpenProject Integration 2.11.1](https://github.com/nextcloud/integration_openproject/releases/tag/v2.11.1) +* [OpenProject Integration 3.0.0](https://github.com/nextcloud/integration_openproject/releases/tag/v3.0.0) — Nextcloud 33 or higher +* [OpenProject Integration 2.11.2](https://github.com/nextcloud/integration_openproject/releases/tag/v2.11.2) — Nextcloud 31, 32 ##### Team folders diff --git a/modules/storages/config/nextcloud_dependencies.yml b/modules/storages/config/nextcloud_dependencies.yml index 0c5fb1ea52b..9388cb00861 100644 --- a/modules/storages/config/nextcloud_dependencies.yml +++ b/modules/storages/config/nextcloud_dependencies.yml @@ -2,7 +2,7 @@ dependencies: integration_app: name: OpenProject Integration url: https://apps.nextcloud.com/apps/integration_openproject - min_version: 2.11.0 + min_version: 2.11.2 team_folders_app: name: Team folders url: https://apps.nextcloud.com/apps/groupfolders diff --git a/modules/storages/spec/common/storages/adapters/providers/nextcloud/queries/capabilities_query_spec.rb b/modules/storages/spec/common/storages/adapters/providers/nextcloud/queries/capabilities_query_spec.rb index b69cd50e720..5fae20b90a6 100644 --- a/modules/storages/spec/common/storages/adapters/providers/nextcloud/queries/capabilities_query_spec.rb +++ b/modules/storages/spec/common/storages/adapters/providers/nextcloud/queries/capabilities_query_spec.rb @@ -71,7 +71,7 @@ module Storages context "if both apps are installed", vcr: "nextcloud/capabilities_success" do let(:app_enabled?) { true } - let(:app_version) { SemanticVersion.parse("2.11.0") } + let(:app_version) { SemanticVersion.parse("2.11.2") } let(:group_folder_enabled?) { true } let(:group_folder_version) { SemanticVersion.parse("20.1.7") } @@ -81,7 +81,7 @@ module Storages context "if team folder app is installed but disabled", vcr: "nextcloud/capabilities_success_team_folders_disabled" do let(:app_enabled?) { true } - let(:app_version) { SemanticVersion.parse("2.11.0") } + let(:app_version) { SemanticVersion.parse("2.11.2") } let(:group_folder_enabled?) { false } let(:group_folder_version) { SemanticVersion.parse("20.1.7") } @@ -91,7 +91,7 @@ module Storages context "if team folder app is not installed", vcr: "nextcloud/capabilities_success_team_folders_not_installed" do let(:app_enabled?) { true } - let(:app_version) { SemanticVersion.parse("2.11.0") } + let(:app_version) { SemanticVersion.parse("2.11.2") } let(:group_folder_enabled?) { false } let(:group_folder_version) { nil } diff --git a/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success.yml b/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success.yml index cc52661769d..ec0c359edcf 100644 --- a/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success.yml +++ b/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success.yml @@ -64,7 +64,7 @@ http_interactions: - '468' body: encoding: UTF-8 - string: '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":1,"string":"32.0.1","edition":"","extendedSupport":false},"capabilities":{"bruteforce":{"delay":0,"allow-listed":false},"app_api":{"version":"32.0.0"},"integration_openproject":{"app_version":"2.11.0","groupfolder_version":"20.1.7","groupfolders_enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a + string: '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":1,"string":"32.0.1","edition":"","extendedSupport":false},"capabilities":{"bruteforce":{"delay":0,"allow-listed":false},"app_api":{"version":"32.0.0"},"integration_openproject":{"app_version":"2.11.2","groupfolder_version":"20.1.7","groupfolders_enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a safe home for all your data","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0","background":"https:\/\/nextcloud.local\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0","favicon":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0"}}}}}' recorded_at: Thu, 15 Jan 2026 15:03:25 GMT recorded_with: VCR 6.4.0 diff --git a/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_disabled.yml b/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_disabled.yml index 5617ad24941..282f88a672d 100644 --- a/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_disabled.yml +++ b/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_disabled.yml @@ -64,7 +64,7 @@ http_interactions: - '465' body: encoding: UTF-8 - string: '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":1,"string":"32.0.1","edition":"","extendedSupport":false},"capabilities":{"bruteforce":{"delay":0,"allow-listed":false},"app_api":{"version":"32.0.0"},"integration_openproject":{"app_version":"2.11.0","groupfolder_version":"20.1.7","groupfolders_enabled":false},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a + string: '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":1,"string":"32.0.1","edition":"","extendedSupport":false},"capabilities":{"bruteforce":{"delay":0,"allow-listed":false},"app_api":{"version":"32.0.0"},"integration_openproject":{"app_version":"2.11.2","groupfolder_version":"20.1.7","groupfolders_enabled":false},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a safe home for all your data","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0","background":"https:\/\/nextcloud.local\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0","favicon":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0"}}}}}' recorded_at: Thu, 15 Jan 2026 15:56:49 GMT recorded_with: VCR 6.4.0 diff --git a/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_not_installed.yml b/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_not_installed.yml index 92bd7ec41b7..d5be89dc82f 100644 --- a/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_not_installed.yml +++ b/modules/storages/spec/support/fixtures/vcr_cassettes/nextcloud/capabilities_success_team_folders_not_installed.yml @@ -64,7 +64,7 @@ http_interactions: - '462' body: encoding: UTF-8 - string: '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":1,"string":"32.0.1","edition":"","extendedSupport":false},"capabilities":{"bruteforce":{"delay":0,"allow-listed":false},"app_api":{"version":"32.0.0"},"integration_openproject":{"app_version":"2.11.0","groupfolder_version":"0","groupfolders_enabled":false},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a + string: '{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":1,"string":"32.0.1","edition":"","extendedSupport":false},"capabilities":{"bruteforce":{"delay":0,"allow-listed":false},"app_api":{"version":"32.0.0"},"integration_openproject":{"app_version":"2.11.2","groupfolder_version":"0","groupfolders_enabled":false},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a safe home for all your data","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0","background":"https:\/\/nextcloud.local\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0","favicon":"https:\/\/nextcloud.local\/core\/img\/logo\/logo.svg?v=0"}}}}}' recorded_at: Thu, 15 Jan 2026 15:59:04 GMT recorded_with: VCR 6.4.0 From 53690c62442aeb6f8ff7936d980730d5a4cad769 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 22:04:20 +0100 Subject: [PATCH 297/435] more comments for the validators --- app/models/project.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 6b229ba630f..a89f6e83279 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -201,13 +201,13 @@ class Project < ApplicationRecord blacklist: RESERVED_IDENTIFIERS, adapter: OpenProject::ActsAsUrl::Adapter::OpActiveRecord # use a custom adapter able to handle edge cases + ### Validators for the legacy underscored identifier format (e.g. "project_one") validates :identifier, presence: true, uniqueness: { case_sensitive: true }, length: { maximum: IDENTIFIER_MAX_LENGTH }, exclusion: RESERVED_IDENTIFIERS, if: ->(p) { p.persisted? || p.identifier.present? } - # Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id. validates :identifier, format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ }, @@ -215,11 +215,10 @@ class Project < ApplicationRecord p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric? } - # When semantic work package IDs with alphanumeric mode are active, identifiers must follow semantic style key rules. + ### Validators for the uppercase identifier format (e.g. "PROJ1") validates :identifier, format: { with: /\A[A-Z]/, message: :must_start_with_letter }, if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? } - validates :identifier, format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters }, length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH }, From 843998750888ec68a52896949202a4d9f7479b07 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 22:09:53 +0100 Subject: [PATCH 298/435] switch to a singular suggestion controller and iron out the route entry --- ...tions_controller.rb => identifier_suggestion_controller.rb} | 2 +- config/initializers/permissions.rb | 2 +- config/routes.rb | 3 +-- ...ifier_suggestions_spec.rb => identifier_suggestion_spec.rb} | 0 4 files changed, 3 insertions(+), 4 deletions(-) rename app/controllers/projects/{identifier_suggestions_controller.rb => identifier_suggestion_controller.rb} (96%) rename spec/requests/projects/{identifier_suggestions_spec.rb => identifier_suggestion_spec.rb} (100%) diff --git a/app/controllers/projects/identifier_suggestions_controller.rb b/app/controllers/projects/identifier_suggestion_controller.rb similarity index 96% rename from app/controllers/projects/identifier_suggestions_controller.rb rename to app/controllers/projects/identifier_suggestion_controller.rb index 0c6fc43fa04..e862677862a 100644 --- a/app/controllers/projects/identifier_suggestions_controller.rb +++ b/app/controllers/projects/identifier_suggestion_controller.rb @@ -29,7 +29,7 @@ #++ module Projects - class IdentifierSuggestionsController < ApplicationController + class IdentifierSuggestionController < ApplicationController before_action :require_login before_action :authorize diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 395b81f253c..effa774ff8b 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -33,7 +33,7 @@ Rails.application.reloader.to_prepare do map.project_module nil, order: 100 do map.permission :add_project, { projects: %i[new create], - "projects/identifier_suggestions": %i[show] }, + "projects/identifier_suggestion": %i[show] }, permissible_on: :global, require: :loggedin, contract_actions: { projects: %i[create] } diff --git a/config/routes.rb b/config/routes.rb index e39edbc31cf..fed385093b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -283,10 +283,9 @@ Rails.application.routes.draw do namespace :projects do resource :menu, only: %i[show] resource :filters, only: %i[show] + resource :identifier_suggestion, only: %i[show], controller: "identifier_suggestion" end - get "projects/identifier_suggestion", to: "projects/identifier_suggestions#show", as: :projects_identifier_suggestion - %w[portfolio project program].each do |workspace_type| resources workspace_type.pluralize, only: %i[new create], diff --git a/spec/requests/projects/identifier_suggestions_spec.rb b/spec/requests/projects/identifier_suggestion_spec.rb similarity index 100% rename from spec/requests/projects/identifier_suggestions_spec.rb rename to spec/requests/projects/identifier_suggestion_spec.rb From c2f35aad932ebc4ac510832e5e83cf0153a28ecb Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 22:33:41 +0100 Subject: [PATCH 299/435] DRY up component specs --- .../projects/copy_form_component_spec.rb | 33 +++---------------- .../components/projects/new_component_spec.rb | 29 +--------------- .../components/identifier_suggestion_data.rb | 32 ++++++++++++++++++ 3 files changed, 37 insertions(+), 57 deletions(-) create mode 100644 spec/support/shared/components/identifier_suggestion_data.rb diff --git a/spec/components/projects/copy_form_component_spec.rb b/spec/components/projects/copy_form_component_spec.rb index f545f35bc26..a53ba4515d7 100644 --- a/spec/components/projects/copy_form_component_spec.rb +++ b/spec/components/projects/copy_form_component_spec.rb @@ -39,38 +39,13 @@ RSpec.describe Projects::CopyFormComponent, type: :component do page end + let(:rendered_component) { render_component } + it "renders a form" do - expect(render_component).to have_css "form" + expect(rendered_component).to have_css "form" end describe "#identifier_suggestion_data" do - it "mounts the Stimulus controller on the wrapper" do - expect(render_component).to have_css("[data-controller='projects--identifier-suggestion']") - end - - it "includes the suggestion URL" do - expect(render_component).to have_css( - "[data-projects--identifier-suggestion-url-value='/projects/identifier_suggestion']" - ) - end - - it "includes the set_name_first translation" do - translation = I18n.t("js.projects.identifier_suggestion.set_name_first") - expect(render_component).to have_css( - "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" - ) - end - - context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do - it "sets mode to semantic" do - expect(render_component).to have_css("[data-projects--identifier-suggestion-mode-value='semantic']") - end - end - - context "with numeric identifiers", with_settings: { work_packages_identifier: "numeric" } do - it "sets mode to legacy" do - expect(render_component).to have_css("[data-projects--identifier-suggestion-mode-value='legacy']") - end - end + it_behaves_like "renders identifier_suggestion_data" end end diff --git a/spec/components/projects/new_component_spec.rb b/spec/components/projects/new_component_spec.rb index b43e6df0ec4..1d93a3427fc 100644 --- a/spec/components/projects/new_component_spec.rb +++ b/spec/components/projects/new_component_spec.rb @@ -83,33 +83,6 @@ RSpec.describe Projects::NewComponent, type: :component do end describe "#identifier_suggestion_data" do - subject(:rendered) { render_inline(described_class.new(project:)) } - - it "mounts the Stimulus controller on the wrapper" do - expect(rendered).to have_css("[data-controller='projects--identifier-suggestion']") - end - - it "includes the suggestion URL" do - expect(rendered).to have_css("[data-projects--identifier-suggestion-url-value='/projects/identifier_suggestion']") - end - - it "includes the set_name_first translation" do - translation = I18n.t("js.projects.identifier_suggestion.set_name_first") - expect(rendered).to have_css( - "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" - ) - end - - context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do - it "sets mode to semantic" do - expect(rendered).to have_css("[data-projects--identifier-suggestion-mode-value='semantic']") - end - end - - context "with numeric identifiers", with_settings: { work_packages_identifier: "numeric" } do - it "sets mode to legacy" do - expect(rendered).to have_css("[data-projects--identifier-suggestion-mode-value='legacy']") - end - end + it_behaves_like "renders identifier_suggestion_data" end end diff --git a/spec/support/shared/components/identifier_suggestion_data.rb b/spec/support/shared/components/identifier_suggestion_data.rb new file mode 100644 index 00000000000..65bf6a26e4e --- /dev/null +++ b/spec/support/shared/components/identifier_suggestion_data.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_examples "renders identifier_suggestion_data" do + it "mounts the Stimulus controller on the wrapper" do + expect(rendered_component).to have_css("[data-controller='projects--identifier-suggestion']") + end + + it "includes the suggestion URL" do + expect(rendered_component).to have_css( + "[data-projects--identifier-suggestion-url-value='/projects/identifier_suggestion']" + ) + end + + it "includes the set_name_first translation" do + translation = I18n.t("js.projects.identifier_suggestion.set_name_first") + expect(rendered_component).to have_css( + "[data-projects--identifier-suggestion-set-name-first-value='#{translation}']" + ) + end + + context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do + it "sets mode to semantic" do + expect(rendered_component).to have_css("[data-projects--identifier-suggestion-mode-value='semantic']") + end + end + + context "with numeric identifiers", with_settings: { work_packages_identifier: "numeric" } do + it "sets mode to legacy" do + expect(rendered_component).to have_css("[data-projects--identifier-suggestion-mode-value='legacy']") + end + end +end From 717e90064c0bf521255f60e4f554d0aed1cfa583 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Thu, 19 Mar 2026 22:37:03 +0100 Subject: [PATCH 300/435] move a let statement --- spec/components/projects/copy_form_component_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/components/projects/copy_form_component_spec.rb b/spec/components/projects/copy_form_component_spec.rb index a53ba4515d7..6a7537d414b 100644 --- a/spec/components/projects/copy_form_component_spec.rb +++ b/spec/components/projects/copy_form_component_spec.rb @@ -33,14 +33,13 @@ require "rails_helper" RSpec.describe Projects::CopyFormComponent, type: :component do let(:source_project) { build_stubbed(:project) } let(:target_project) { Project.new(attributes_for(:project).except(:name)) } + let(:rendered_component) { render_component } def render_component(**params) render_inline(described_class.new(source_project:, target_project:, **params)) page end - let(:rendered_component) { render_component } - it "renders a form" do expect(rendered_component).to have_css "form" end From 2ca2b1cbdcc5a42ce21481449ad2c6df5cbfa468 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:16:49 +0000 Subject: [PATCH 301/435] Bump flatted from 3.3.1 to 3.4.2 in /frontend Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.1 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.1...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 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 248c5a0e0c5..7ea055e8260 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14966,9 +14966,9 @@ "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==" }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "node_modules/follow-redirects": { @@ -35507,9 +35507,9 @@ "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==" }, "flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "follow-redirects": { From c7afb4968f767dbd0d23c66d24210915b8b3c5a1 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 19 Mar 2026 15:12:38 +0100 Subject: [PATCH 302/435] Get inplaceEditField for customField dynamically by the format instead of registring them all directly --- .../common/inplace_edit_field_component.rb | 9 ++-- .../calculated_value_input_component.rb | 4 ++ .../display_fields_component.sass | 1 + .../display_fields/link_input_component.rb | 3 +- .../inplace_edit_fields_controller.rb | 45 ++++++++++--------- app/models/custom_field.rb | 6 +-- config/initializers/inplace_edit_fields.rb | 6 --- .../inplace_edit/field_registry.rb | 13 +++--- .../attribute_help_texts_spec.rb | 6 ++- .../inplace_edit/field_registry_spec.rb | 23 +++++----- spec/models/custom_field_spec.rb | 24 ---------- 11 files changed, 60 insertions(+), 80 deletions(-) diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index 95f16fdb033..20478736de4 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -60,7 +60,11 @@ module OpenProject end def field_class - OpenProject::InplaceEdit::FieldRegistry.fetch(attribute) + if custom_field? + OpenProject::InplaceEdit::FieldRegistry.fetch_for_custom_field_format(custom_field&.field_format) + else + OpenProject::InplaceEdit::FieldRegistry.fetch(attribute) + end end def edit_field_component(form) @@ -212,8 +216,7 @@ module OpenProject # For custom fields, check the is_required attribute custom_field&.is_required || false else - # For regular model attributes, check ActiveRecord validations - model.class.validators_on(attribute).any?(ActiveRecord::Validations::PresenceValidator) + false end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb index 85fe9f4e4c6..30651ff3b3a 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/calculated_value_input_component.rb @@ -38,10 +38,14 @@ module OpenProject attr_reader :model, :attribute + # If the writable attribute is not explicitly listed as an argument, + # it will be interpreted as one of the system_arguments and thus overwrite the `writable: false` + # rubocop:disable Lint/UnusedMethodArgument def initialize(model:, attribute:, writable: nil, truncated: false, has_comment: false, show_comment: false, **system_arguments) super(model:, attribute:, writable: false, truncated:, has_comment:, show_comment:, **system_arguments) end + # rubocop:enable Lint/UnusedMethodArgument def render_calculation_error error = custom_field&.first_calculation_error(model) diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass b/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass index a0c26e0c968..ea3a61c1ea7 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_fields_component.sass @@ -3,6 +3,7 @@ padding: var(--base-size-4) var(--base-size-8) border: 1px solid transparent border-radius: var(--borderRadius-medium) + @include text-shortener(false) &:hover, &:focus border-color: var(--borderColor-default) diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb index 7e5c925edda..3103a40bb18 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/link_input_component.rb @@ -51,8 +51,7 @@ module OpenProject link = Addressable::URI.parse(href) return href unless link - target = link.host == Setting.host_without_protocol ? "_top" : "_blank" - render(Primer::Beta::Link.new(href:, rel: "noopener noreferrer", target:)) do + render(Primer::Beta::Link.new(href:, rel: "noopener noreferrer")) do href end end diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index dc9c64904db..e4886e9c49c 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -46,27 +46,8 @@ class InplaceEditFieldsController < ApplicationController def update success = invoke_update_handler - - if success - render_success_flash_message_via_turbo_stream( - message: I18n.t(:notice_successful_update) - ) - close_dialog_via_turbo_stream(dialog_id) if dialog_id - refresh_calculated_dependents - end - - if !success && dialog_id - replace_via_turbo_stream( - component: dialog_field_component, - status: :unprocessable_entity - ) - else - replace_via_turbo_stream( - component: component(enforce_edit_mode: !success), - status: success ? :ok : :unprocessable_entity - ) - end - + handle_update_success if success + replace_field_component(success) respond_with_turbo_streams rescue ArgumentError head :not_found @@ -96,6 +77,28 @@ class InplaceEditFieldsController < ApplicationController handler.call(model: @model, params: permitted_params, user: current_user) end + def handle_update_success + render_success_flash_message_via_turbo_stream( + message: I18n.t(:notice_successful_update) + ) + close_dialog_via_turbo_stream(dialog_id) if dialog_id + refresh_calculated_dependents + end + + def replace_field_component(success) + if !success && dialog_id + replace_via_turbo_stream( + component: dialog_field_component, + status: :unprocessable_entity + ) + else + replace_via_turbo_stream( + component: component(enforce_edit_mode: !success), + status: success ? :ok : :unprocessable_entity + ) + end + end + def find_model model_class = resolve_model_class(params[:model]) @model = model_class.visible.find(params[:id]) diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index bbf10359925..fc5136fa365 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -88,7 +88,7 @@ class CustomField < ApplicationRecord validates :has_comment, absence: true, unless: :can_have_comment? before_validation :check_searchability - after_create :register_inplace_edit_component + after_destroy :destroy_help_text # make sure int, float, date, and bool are not searchable @@ -482,8 +482,4 @@ class CustomField < ApplicationRecord .where(attribute_name:) .destroy_all end - - def register_inplace_edit_component - OpenProject::InplaceEdit::FieldRegistry.register_custom_field(id, field_format) - end end diff --git a/config/initializers/inplace_edit_fields.rb b/config/initializers/inplace_edit_fields.rb index 432fbb7e7c9..4898d3414dd 100644 --- a/config/initializers/inplace_edit_fields.rb +++ b/config/initializers/inplace_edit_fields.rb @@ -53,12 +53,6 @@ Rails.application.config.to_prepare do OpenProject::InplaceEdit::FieldRegistry.register_custom_field_format_mappings(custom_field_format_mappings) - if CustomField.table_exists? - CustomField.pluck(:id, :field_format).each do |id, field_format| - OpenProject::InplaceEdit::FieldRegistry.register_custom_field(id, field_format) - end - end - # Register the update handler per model OpenProject::InplaceEdit::UpdateRegistry.register(Project, handler: OpenProject::InplaceEdit::Handlers::ProjectUpdate, diff --git a/lib/open_project/inplace_edit/field_registry.rb b/lib/open_project/inplace_edit/field_registry.rb index 9449408193d..5f550ac0328 100644 --- a/lib/open_project/inplace_edit/field_registry.rb +++ b/lib/open_project/inplace_edit/field_registry.rb @@ -44,21 +44,22 @@ module OpenProject @custom_field_format_mappings = mappings end - def register_custom_field(id, field_format) - component = @custom_field_format_mappings[field_format] - register("custom_field_#{id}", component) if component - end - def fetch(attribute_name) @registry.fetch(attribute_name.to_s) { Common::InplaceEditFields::TextInputComponent } end + def fetch_for_custom_field_format(field_format) + return Common::InplaceEditFields::TextInputComponent if field_format.nil? + + @custom_field_format_mappings.fetch(field_format.to_s) { Common::InplaceEditFields::TextInputComponent } + end + @default = new class << self attr_reader :default - delegate :register, :fetch, :register_custom_field_format_mappings, :register_custom_field, to: :@default + delegate :register, :fetch, :fetch_for_custom_field_format, :register_custom_field_format_mappings, to: :@default end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb b/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb index b5793f26d75..894d3051034 100644 --- a/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/attribute_help_texts_spec.rb @@ -46,7 +46,11 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute context "without attribute help texts defined" do it "shows field labels without help text link" do input_fields.each do |custom_field| - field = overview_page.open_inplace_edit_field_for_custom_field(custom_field) + field = if custom_field == text_project_custom_field + overview_page.open_modal_for_custom_field(custom_field) + else + overview_page.open_inplace_edit_field_for_custom_field(custom_field) + end field.expect_field_label_without_help_text custom_field.name field.close end diff --git a/spec/lib/open_project/inplace_edit/field_registry_spec.rb b/spec/lib/open_project/inplace_edit/field_registry_spec.rb index b9e96b87904..7977d2736f4 100644 --- a/spec/lib/open_project/inplace_edit/field_registry_spec.rb +++ b/spec/lib/open_project/inplace_edit/field_registry_spec.rb @@ -63,33 +63,32 @@ RSpec.describe OpenProject::InplaceEdit::FieldRegistry do end describe "#register_custom_field_format_mappings" do - it "stores format-to-component mappings used by register_custom_field" do + it "stores format-to-component mappings used by fetch_for_custom_field_format" do text_component = Class.new registry.register_custom_field_format_mappings("text" => text_component) - registry.register_custom_field(42, "text") - - expect(registry.fetch("custom_field_42")).to eq(text_component) + expect(registry.fetch_for_custom_field_format("text")).to eq(text_component) end end - describe "#register_custom_field" do + describe "#fetch_for_custom_field_format" do let(:text_component) { Class.new } before do registry.register_custom_field_format_mappings("text" => text_component) end - it "registers the correct component for the given field format" do - registry.register_custom_field(1, "text") - - expect(registry.fetch("custom_field_1")).to eq(text_component) + it "returns the correct component for the given field format" do + expect(registry.fetch_for_custom_field_format("text")).to eq(text_component) end - it "does nothing when the format has no mapping" do - registry.register_custom_field(2, "unknown_format") + it "falls back to TextInputComponent when the format has no mapping" do + expect(registry.fetch_for_custom_field_format("unknown_format")) + .to eq(OpenProject::Common::InplaceEditFields::TextInputComponent) + end - expect(registry.fetch("custom_field_2")) + it "falls back to TextInputComponent when field_format is nil" do + expect(registry.fetch_for_custom_field_format(nil)) .to eq(OpenProject::Common::InplaceEditFields::TextInputComponent) end end diff --git a/spec/models/custom_field_spec.rb b/spec/models/custom_field_spec.rb index 3e64255a8b6..96b30f29686 100644 --- a/spec/models/custom_field_spec.rb +++ b/spec/models/custom_field_spec.rb @@ -742,28 +742,4 @@ RSpec.describe CustomField do end end end - - describe "after_create callback" do - it "registers the custom field in the inplace edit field registry" do - custom_field = build(:custom_field, field_format: "string") - - allow(OpenProject::InplaceEdit::FieldRegistry).to receive(:register_custom_field) - - custom_field.save! - - expect(OpenProject::InplaceEdit::FieldRegistry) - .to have_received(:register_custom_field) - .with(anything, "string") - end - - it "does not re-register when updated" do - custom_field = create(:custom_field, field_format: "string") - - allow(OpenProject::InplaceEdit::FieldRegistry).to receive(:register_custom_field) - - custom_field.update!(name: "Updated name") - - expect(OpenProject::InplaceEdit::FieldRegistry).not_to have_received(:register_custom_field) - end - end end From 2d92b5ddcf24d3dbabe652c7d280704bf8f79fd5 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 20 Mar 2026 08:42:27 +0100 Subject: [PATCH 303/435] Use CustomValue.formatted_value instead of formatting the values manually --- .../display_fields/display_field_component.rb | 31 +++++---- .../hierarchy_list_component.rb | 59 ---------------- .../display_fields/select_list_component.rb | 31 ++------- .../user_select_list_component.rb | 39 ++++------- .../hierarchy_list_component.rb | 4 -- .../patterns/06-inplace-edit-fields.md.erb | 4 -- .../hierarchy_list_component_spec.rb | 69 ------------------- 7 files changed, 35 insertions(+), 202 deletions(-) delete mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb delete mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 022fa37655d..1abd1f5d61d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -49,16 +49,10 @@ module OpenProject end def render_display_value - value = model.public_send(attribute) - - if value.is_a?(TrueClass) || value.is_a?(FalseClass) - boolean_display_value(value) - elsif value.is_a?(Date) || value.is_a?(Time) - helpers.format_date(value) - elsif value.present? && value != [nil] - format_present_value(value) + if custom_field? + render_custom_field_display_value else - t("placeholders.default") + render_attribute_display_value end end @@ -151,11 +145,22 @@ module OpenProject "op-inplace-edit--display-field#{' op-inplace-edit--display-field_clickable' if clickable}" end - def format_present_value(value) - if custom_field? - helpers.format_value(value, custom_field) - else + def render_custom_field_display_value + values = custom_field_values.reject { |v| v.value.blank? } + values.present? ? values.map(&:formatted_value).join(", ") : t("placeholders.default") + end + + def render_attribute_display_value + value = model.public_send(attribute) + + if value.is_a?(TrueClass) || value.is_a?(FalseClass) + boolean_display_value(value) + elsif value.is_a?(Date) || value.is_a?(Time) + helpers.format_date(value) + elsif value.present? value.to_s + else + t("placeholders.default") end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb deleted file mode 100644 index f94c2bfac33..00000000000 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module OpenProject - module Common - module InplaceEditFields - module DisplayFields - class HierarchyListComponent < DisplayFieldComponent - def render_display_value - items = hierarchy_items - - if items.empty? - t("placeholders.default") - elsif custom_field.multi_value? - items.join(", ") - else - items.first.to_s - end - end - - private - - def hierarchy_items - custom_field_values.filter_map do |cv| - CustomField::Hierarchy::Item.find_by(id: cv.value&.to_i) - end - end - end - end - end - end -end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb index 654b85499db..13e98de36c9 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -33,36 +33,13 @@ module OpenProject module InplaceEditFields module DisplayFields class SelectListComponent < DisplayFieldComponent - include CustomFieldsHelper - - attr_reader :model, :attribute, :writable - - def render_display_value - value = model.public_send(attribute) - - if value.present? && value != [nil] - render_value(value) - else - t("placeholders.default") - end - end - private - def render_value(value) - if custom_field? - formatted_custom_field_values.presence || t("placeholders.default") - else - value.is_a?(Array) ? value.join(", ") : value.to_s - end - end + def render_attribute_display_value + value = model.public_send(attribute) + return t("placeholders.default") unless value.present? && value != [nil] - def formatted_custom_field_values - return @formatted_custom_field_values if defined?(@formatted_custom_field_values) - - values = custom_field_values.map { |v| format_value(v.value, custom_field) } - - @formatted_custom_field_values = custom_field&.multi_value? ? values.join(", ") : values.first + value.is_a?(Array) ? value.join(", ") : value.to_s end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb index b56a3ee43ae..cbd31fd252e 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb @@ -33,35 +33,22 @@ module OpenProject module InplaceEditFields module DisplayFields class UserSelectListComponent < SelectListComponent - include CustomFieldsHelper - - attr_reader :model, :attribute, :writable - - def formatted_custom_field_values - return @formatted_custom_field_values if defined?(@formatted_custom_field_values) - - cf_values = custom_field_values - - users = cf_values.filter_map(&:typed_value) - - @formatted_custom_field_values = if custom_field.multi_value? - flex_layout do |avatar_container| - users.each do |user| - avatar_container.with_row do - render_avatar(user) - end - end - end - else - render_avatar(users.first) - end - end - private - def render_avatar(user) - return unless user + def render_custom_field_display_value + users = custom_field_values.filter_map(&:typed_value) + return t("placeholders.default") if users.empty? + if custom_field.multi_value? + flex_layout do |avatar_container| + users.each { |user| avatar_container.with_row { render_avatar(user) } } + end + else + render_avatar(users.first) + end + end + + def render_avatar(user) render(::Users::AvatarComponent.new(user:, size: :mini)) end end diff --git a/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb b/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb index caeda6006f5..1b8524f41e4 100644 --- a/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb @@ -34,10 +34,6 @@ module OpenProject class HierarchyListComponent < BaseFieldComponent include CustomFieldHierarchyTreeViewHelper - def self.display_class - DisplayFields::HierarchyListComponent - end - def self.open_in_dialog? true end diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index 22d7125c5a3..3ec1fc7dd0b 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -201,10 +201,6 @@ end class HierarchyListComponent < BaseFieldComponent include CustomFieldHierarchyTreeViewHelper - def self.display_class - DisplayFields::HierarchyListComponent - end - def self.open_in_dialog? true end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb deleted file mode 100644 index 437a67fd8e9..00000000000 --- a/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ -require "rails_helper" - -RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::HierarchyListComponent, - type: :component, with_ee: [:custom_field_hierarchies] do - include ViewComponent::TestHelpers - - let(:project) { create(:project) } - let(:custom_field) { create(:project_custom_field, :hierarchy) } - let(:attribute) { custom_field.attribute_name.to_sym } - - it "renders a placeholder when no value is set" do - render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) - - expect(rendered_content).to have_text(I18n.t("placeholders.default")) - end - - it "renders the item label for a single hierarchy value" do - item = create(:hierarchy_item, label: "Alpha") - create(:custom_value, :skip_validations, customized: project, custom_field:, value: item.id.to_s) - - render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) - - expect(rendered_content).to have_text("Alpha") - end - - context "with a multi-value hierarchy field" do - let(:custom_field) { create(:project_custom_field, :multi_hierarchy) } - - it "renders multiple item labels joined by comma" do - item1 = create(:hierarchy_item, label: "Alpha") - item2 = create(:hierarchy_item, label: "Beta") - create(:custom_value, :skip_validations, customized: project, custom_field:, value: item1.id.to_s) - create(:custom_value, :skip_validations, customized: project, custom_field:, value: item2.id.to_s) - - render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) - - expect(rendered_content).to have_text("Alpha, Beta") - end - end -end From 6dc12293008239659c65c084142ddd96b4b962d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 18 Feb 2026 09:47:30 +0100 Subject: [PATCH 304/435] Remove unused code --- app/helpers/application_helper.rb | 24 ------ app/views/common/list_attachments.json.erb | 37 -------- app/views/projects/destroy_info.html.erb | 84 ------------------- .../roles/autocomplete_for_role.json.erb | 43 ---------- 4 files changed, 188 deletions(-) delete mode 100644 app/views/common/list_attachments.json.erb delete mode 100644 app/views/projects/destroy_info.html.erb delete mode 100644 app/views/roles/autocomplete_for_role.json.erb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 13d7784cf30..588b4668c23 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -124,30 +124,6 @@ module ApplicationHelper Project.project_tree(projects, &) end - def project_nested_ul(projects, &) - s = +"" - if projects.any? - ancestors = [] - Project.project_tree(projects) do |project, _level| - if ancestors.empty? || project.is_descendant_of?(ancestors.last) - s << "
    \n" - else - ancestors.pop - s << "" - while ancestors.any? && !project.is_descendant_of?(ancestors.last) - ancestors.pop - s << "
\n" - end - end - s << "
  • " - s << yield(project).to_s - ancestors << project - end - s << ("
  • \n" * ancestors.size) - end - s.html_safe - end - def principals_check_box_tags(name, principals) labeled_check_box_tags(name, principals, title: :user_status_i18n, diff --git a/app/views/common/list_attachments.json.erb b/app/views/common/list_attachments.json.erb deleted file mode 100644 index f8f26dad3fb..00000000000 --- a/app/views/common/list_attachments.json.erb +++ /dev/null @@ -1,37 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> - -<%= attachments.select { |a| a.readable? and a.visible? } - .map do |a| - { content_type: CGI::escape_html(a.content_type), - filename: CGI::escape_html(a.filename), - description: CGI::escape_html(a.description), - url: url_for(controller: "/attachments", action: "show", id: a, filename: a.filename), - is_image: !!a.image? } # doing the !! as image? for whatever reason returns null or a number - end.to_json.html_safe %> diff --git a/app/views/projects/destroy_info.html.erb b/app/views/projects/destroy_info.html.erb deleted file mode 100644 index a27bf2fff03..00000000000 --- a/app/views/projects/destroy_info.html.erb +++ /dev/null @@ -1,84 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> -<%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title { t("project.destroy.title", name: @project_to_destroy) } - header.with_breadcrumbs( - [{ href: project_overview_path(@project_to_destroy.id), text: @project_to_destroy.name }, - { href: project_settings_general_path(@project_to_destroy.id), text: I18n.t("label_project_settings") }, - t("project.destroy.title", name: @project_to_destroy)] - ) - end -%> - -<%= styled_form_tag( - project_path(@project_to_destroy), - class: "danger-zone", - data: { turbo: false }, - method: :delete - ) do %> -
    -

    - <%= t("project.destroy.title", name: "#{h(@project_to_destroy)}").html_safe %> -

    -

    - <%= t("project.destroy.confirmation", identifier: @project_to_destroy.identifier) %> - <% if @project_to_destroy.descendants.any? %> -
    - <% descendants = h(@project_to_destroy.descendants.collect(&:to_s).join(", ")) %> - <%= t("project.destroy.subprojects_confirmation", value: "#{h(descendants)}").html_safe %> - <% end %> -

    -
      -
    • <%= t("project.destroy.project_delete_result_1") %>
    • - <% if has_managed_project_folders?(@project_to_destroy) %> -
    • <%= t("project.destroy.project_delete_result_2") %>
    • - <% end %> -
    -

    - - <%= t("project.destroy.info") %> -

    -

    - <%= t("project.destroy.project_verification", name: "#{h(@project_to_destroy)}").html_safe %> -

    -
    - - <%= styled_button_tag title: t(:button_delete), class: "-primary", disabled: true do - concat content_tag :i, "", class: "button--icon icon-delete" - concat content_tag :span, t(:button_delete), class: "button--text" - end %> - <%= link_to projects_path, - title: t(:button_cancel), - class: "button -with-icon icon-cancel" do %> - <%= t(:button_cancel) %> - <% end %> -
    -
    -<% end %> diff --git a/app/views/roles/autocomplete_for_role.json.erb b/app/views/roles/autocomplete_for_role.json.erb deleted file mode 100644 index dd3bf3f3441..00000000000 --- a/app/views/roles/autocomplete_for_role.json.erb +++ /dev/null @@ -1,43 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> - -{ - "results": - { - "items":[ - <% @roles.each_with_index do |role, ix| %> - { - "id": <%= role.id.to_json.html_safe %>, - "name": <%= role.name.to_json.html_safe %> - } <%= "," unless ix == @roles.length - 1 %> -<% end %> ], - "total": <%= @total || @roles.size %>, - "more": <%= @more || 0 %> -} -} From b06e1d4f1baaab8db03da268b57cf01480c635a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 18 Feb 2026 10:49:35 +0100 Subject: [PATCH 305/435] Keep and comment html_safe usages we need --- .../activities/item_subtitle_component.html.erb | 2 +- .../activities/item_subtitle_component.rb | 6 ++++-- .../open_project/common/attribute_component.rb | 14 +++++--------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/components/activities/item_subtitle_component.html.erb b/app/components/activities/item_subtitle_component.html.erb index 0016a82667a..4721b1d0aa2 100644 --- a/app/components/activities/item_subtitle_component.html.erb +++ b/app/components/activities/item_subtitle_component.html.erb @@ -33,6 +33,6 @@ See COPYRIGHT and LICENSE files for more details. i18n_key, user: user_html, datetime: datetime_html - ).html_safe + ).html_safe # OG: html_safe usage has been double-checked and would otherwise require a lot of i18n key change %>
    diff --git a/app/components/activities/item_subtitle_component.rb b/app/components/activities/item_subtitle_component.rb index 81de3fd04be..a9669840364 100644 --- a/app/components/activities/item_subtitle_component.rb +++ b/app/components/activities/item_subtitle_component.rb @@ -42,10 +42,12 @@ class Activities::ItemSubtitleComponent < ViewComponent::Base def user_html return unless @user - [ + parts = [ helpers.avatar(@user, size: "mini"), helpers.content_tag("span", helpers.link_to_user(@user), class: %w[spot-caption spot-caption_bold]) - ].join(" ") + ] + + safe_join(parts, " ") end def datetime_html diff --git a/app/components/open_project/common/attribute_component.rb b/app/components/open_project/common/attribute_component.rb index 5ebbdb8a56d..0d356e13599 100644 --- a/app/components/open_project/common/attribute_component.rb +++ b/app/components/open_project/common/attribute_component.rb @@ -78,24 +78,20 @@ module OpenProject private - def first_paragraph_content - return unless first_paragraph_ast - - first_paragraph_ast - .inner_html - .html_safe # rubocop:disable Rails/OutputSafety - end - + # rubocop:disable Rails/OutputSafety + # OG: html_safe double-checked and expected here, + # output is coming from format_text which we output elsewhere, too. def first_paragraph @first_paragraph ||= if body_children.any? body_children .first .inner_html - .html_safe # rubocop:disable Rails/OutputSafety + .html_safe else "" end end + # rubocop:enable Rails/OutputSafety def first_paragraph_ast @first_paragraph_ast ||= text_ast From 50e16740ad427430e528345ea53533ba0ad1abe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 18 Feb 2026 16:16:21 +0100 Subject: [PATCH 306/435] Allow link attributes on link_translate --- lib_static/redmine/i18n.rb | 12 ++++---- .../job_status/dialog/body_component.html.erb | 28 ++++++++----------- modules/job_status/config/locales/en.yml | 3 +- .../storages/admin/storages/new.html.erb | 8 +++--- modules/storages/config/locales/en.yml | 2 +- 5 files changed, 25 insertions(+), 28 deletions(-) diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index c190a6ed181..300ce8839d5 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -112,15 +112,16 @@ module Redmine # link_translate(:logged_out, links: { login: login_url }) # # @param i18n_key [String] The I18n key to translate. + # @param i18n_args [Hash] Arguments passed to I18n.t() call # @param links [Hash] Link names mapped to URLs. # @param external [Boolean] Whether the links should be opened as external links, i.e. in a new tab (default: true) # @param underline [Boolean] Whether to underline links inserted into the text (default: true) - def link_translate(i18n_key, links: {}, external: true, underline: true) + def link_translate(i18n_key, i18n_args: {}, links: {}, external: true, underline: true, **) output = ActiveSupport::SafeBuffer.new output << ApplicationController.helpers.t(i18n_key.to_s) output.html_safe_gsub(link_regex) do - create_link_content($3, $2, external:, links:, underline:) + create_link_content($3, $2, external:, links:, underline:, **) end end @@ -269,8 +270,8 @@ module Redmine private - def create_link_content(key, text, external:, links:, underline:) - link_reference = links[key.to_sym] + def create_link_content(key, text, external:, links:, underline:, **link_arguments) + link_reference = links.fetch(key.to_sym) href = case link_reference when Array @@ -282,10 +283,11 @@ module Redmine render( Primer::Beta::Link.new( + **link_arguments, href:, target:, underline:, - data: { allow_external_link: true } + data: { allow_external_link: true }, ) ) do |l| l.with_trailing_visual_icon(icon: :"link-external") if external diff --git a/modules/job_status/app/components/job_status/dialog/body_component.html.erb b/modules/job_status/app/components/job_status/dialog/body_component.html.erb index d734a0c5120..69dfd0e149c 100644 --- a/modules/job_status/app/components/job_status/dialog/body_component.html.erb +++ b/modules/job_status/app/components/job_status/dialog/body_component.html.erb @@ -32,23 +32,19 @@ flex.with_row { render(Primer::Beta::Text.new) { I18n.t("job_status_dialog.download_starts") } } flex.with_row do render(Primer::Beta::Text.new) do - I18n.t( + helpers.link_translate( "job_status_dialog.link_to_download", - link: (render( - Primer::Beta::Link.new( - href: download_url, - underline: true, - target: "_blank", - type: mime_type, - # omit download attribute for PDF, - # so they open in a tab - if supported and activated in the browser - download: mime_type_pdf? ? nil : "download", - data: { - "job-status-polling-target": "download" - } - ) - ) { I18n.t("job_status_dialog.click_here") }) - ).html_safe + links: { link_to_download: download_url }, + underline: true, + external: true, + type: mime_type, + # omit download attribute for PDF, + # so they open in a tab - if supported and activated in the browser + download: mime_type_pdf? ? nil : "download", + data: { + "job-status-polling-target": "download" + }) + ) end end end diff --git a/modules/job_status/config/locales/en.yml b/modules/job_status/config/locales/en.yml index b3d995d279a..f1345bce658 100644 --- a/modules/job_status/config/locales/en.yml +++ b/modules/job_status/config/locales/en.yml @@ -5,8 +5,7 @@ en: description: "Listing and status of background jobs." job_status_dialog: download_starts: 'The download should start automatically.' - link_to_download: 'Or, %{link} to download.' - click_here: 'click here' + link_to_download: 'Or, [click here](download_link) to download.' title: 'Background job status' redirect: 'You are being redirected.' redirect_link: 'Please click here to continue.' diff --git a/modules/storages/app/views/storages/admin/storages/new.html.erb b/modules/storages/app/views/storages/admin/storages/new.html.erb index 9178b323b94..86edfab3b85 100644 --- a/modules/storages/app/views/storages/admin/storages/new.html.erb +++ b/modules/storages/app/views/storages/admin/storages/new.html.erb @@ -17,10 +17,10 @@ <% header.with_description(test_selector: 'storage-new-page-header--description') do %> <%= - t("storages.instructions.new_storage", - provider_link: ::OpenProject::Static::Links.url_for(:storage_docs, :"#{@storage}_setup"), - provider_name: I18n.t("storages.provider_types.#{@storage}.name") - ).html_safe + link_translate("storages.instructions.new_storage", + i18n_args: { provider_name: I18n.t("storages.provider_types.#{@storage}.name") }, + links: { docs_url: [:storage_docs, :"#{@storage}_setup"] }, + external: true) %> <% end %> <% end %> diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml index 42b8727a57e..49b2a4cd140 100644 --- a/modules/storages/config/locales/en.yml +++ b/modules/storages/config/locales/en.yml @@ -401,7 +401,7 @@ en: 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}.' name: Give your storage a name so that users can differentiate between multiple storages. - new_storage: Read our documentation on
    setting up a %{provider_name} file storage integration for more information. + new_storage: Read our documentation on [setting up a %{provider_name} file storage](docs_url) integration for more information. nextcloud: application_link_text: application “Integration OpenProject” integration: Nextcloud Administration / OpenProject From 19427d27592c33d6a5f18e8e779fd6b2d401f10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 19 Feb 2026 11:27:31 +0100 Subject: [PATCH 307/435] Add scanner for link_translate --- config/i18n-tasks.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 311fba7b0b7..3435d4985de 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -180,3 +180,8 @@ ignore_unused: # # <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', # patterns: [['\bSpree\.t[( ]\s*%{key}', 'spree.%{key}']] %> + +## Detect link_translate("some.key", ...) calls, which are not picked up by the default scanner +## because they use a custom helper rather than t() / I18n.t(). +<% I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', + patterns: [['\blink_translate\s*\(\s*%{key}', '%{key}']] %> From 00317e7197466aa320b51bf422295ed89b2aac7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 19 Feb 2026 11:50:27 +0100 Subject: [PATCH 308/435] Ensure we use renderer, not AC render method itself --- lib_static/redmine/i18n.rb | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index 300ce8839d5..e6b739df5a0 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -118,7 +118,7 @@ module Redmine # @param underline [Boolean] Whether to underline links inserted into the text (default: true) def link_translate(i18n_key, i18n_args: {}, links: {}, external: true, underline: true, **) output = ActiveSupport::SafeBuffer.new - output << ApplicationController.helpers.t(i18n_key.to_s) + output << ApplicationController.helpers.t(i18n_key.to_s, **i18n_args) output.html_safe_gsub(link_regex) do create_link_content($3, $2, external:, links:, underline:, **) @@ -281,18 +281,21 @@ module Redmine end target = external ? "_blank" : nil - render( - Primer::Beta::Link.new( - **link_arguments, - href:, - target:, - underline:, - data: { allow_external_link: true }, - ) - ) do |l| - l.with_trailing_visual_icon(icon: :"link-external") if external - text - end + # Make sure we use AC renderer here to not affect the performed? state + # when rendering this in e.g., a before action. + # Note: ActionController::Renderer#render does not pass blocks through to + # ViewComponent, so slots and content must be set before rendering. + component = Primer::Beta::Link.new( + **link_arguments, + href:, + target:, + underline:, + data: { allow_external_link: true }, + ) + component.with_trailing_visual_icon(icon: :"link-external") if external + component.with_content(text) + + ApplicationController.renderer.render(component, layout: false) end end end From a1d0a00958085e9aabe9ffaa184b36d6f9b38b5d Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 20 Mar 2026 09:02:31 +0100 Subject: [PATCH 309/435] Update label and caption for aggregation settings field --- config/locales/en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 53810ac3649..5a921de7ff6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -117,7 +117,7 @@ en: jemalloc_allocator: Jemalloc memory allocator journal_aggregation: caption: > - 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 the [webhook](webhook_link) delay. + User actions on a work package (changing description, status, values, or writing comments) are grouped if performed within this period. It also controls notification and [webhook](webhook_link) delays. import: title: "Import" jira: @@ -5065,7 +5065,7 @@ en: setting_work_package_properties: "Work package properties" setting_work_package_startdate_is_adddate: "Use current date as start date for new work packages" setting_work_packages_projects_export_limit: "Work packages / Projects export limit" - setting_journal_aggregation_time_minutes: "User actions aggregated within" + setting_journal_aggregation_time_minutes: "Aggregation period" setting_log_requesting_user: "Log user login, name, and mail address for all requests" setting_login_required: "Authentication required" setting_login_required_caption: "When checked, all requests to the application have to be authenticated." From d9339747e9642ffd23f515af033e07d90192fce7 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 20 Mar 2026 09:05:30 +0100 Subject: [PATCH 310/435] Revert "Use CustomValue.formatted_value instead of formatting the values manually" This reverts commit 2d92b5ddcf24d3dbabe652c7d280704bf8f79fd5. --- .../display_fields/display_field_component.rb | 31 ++++----- .../hierarchy_list_component.rb | 59 ++++++++++++++++ .../display_fields/select_list_component.rb | 31 +++++++-- .../user_select_list_component.rb | 35 +++++++--- .../hierarchy_list_component.rb | 4 ++ .../patterns/06-inplace-edit-fields.md.erb | 4 ++ .../hierarchy_list_component_spec.rb | 69 +++++++++++++++++++ 7 files changed, 200 insertions(+), 33 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb create mode 100644 spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 1abd1f5d61d..022fa37655d 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -49,10 +49,16 @@ module OpenProject end def render_display_value - if custom_field? - render_custom_field_display_value + value = model.public_send(attribute) + + if value.is_a?(TrueClass) || value.is_a?(FalseClass) + boolean_display_value(value) + elsif value.is_a?(Date) || value.is_a?(Time) + helpers.format_date(value) + elsif value.present? && value != [nil] + format_present_value(value) else - render_attribute_display_value + t("placeholders.default") end end @@ -145,22 +151,11 @@ module OpenProject "op-inplace-edit--display-field#{' op-inplace-edit--display-field_clickable' if clickable}" end - def render_custom_field_display_value - values = custom_field_values.reject { |v| v.value.blank? } - values.present? ? values.map(&:formatted_value).join(", ") : t("placeholders.default") - end - - def render_attribute_display_value - value = model.public_send(attribute) - - if value.is_a?(TrueClass) || value.is_a?(FalseClass) - boolean_display_value(value) - elsif value.is_a?(Date) || value.is_a?(Time) - helpers.format_date(value) - elsif value.present? - value.to_s + def format_present_value(value) + if custom_field? + helpers.format_value(value, custom_field) else - t("placeholders.default") + value.to_s end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb new file mode 100644 index 00000000000..f94c2bfac33 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + module InplaceEditFields + module DisplayFields + class HierarchyListComponent < DisplayFieldComponent + def render_display_value + items = hierarchy_items + + if items.empty? + t("placeholders.default") + elsif custom_field.multi_value? + items.join(", ") + else + items.first.to_s + end + end + + private + + def hierarchy_items + custom_field_values.filter_map do |cv| + CustomField::Hierarchy::Item.find_by(id: cv.value&.to_i) + end + end + end + end + end + end +end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb index 13e98de36c9..654b85499db 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/select_list_component.rb @@ -33,13 +33,36 @@ module OpenProject module InplaceEditFields module DisplayFields class SelectListComponent < DisplayFieldComponent + include CustomFieldsHelper + + attr_reader :model, :attribute, :writable + + def render_display_value + value = model.public_send(attribute) + + if value.present? && value != [nil] + render_value(value) + else + t("placeholders.default") + end + end + private - def render_attribute_display_value - value = model.public_send(attribute) - return t("placeholders.default") unless value.present? && value != [nil] + def render_value(value) + if custom_field? + formatted_custom_field_values.presence || t("placeholders.default") + else + value.is_a?(Array) ? value.join(", ") : value.to_s + end + end - value.is_a?(Array) ? value.join(", ") : value.to_s + def formatted_custom_field_values + return @formatted_custom_field_values if defined?(@formatted_custom_field_values) + + values = custom_field_values.map { |v| format_value(v.value, custom_field) } + + @formatted_custom_field_values = custom_field&.multi_value? ? values.join(", ") : values.first end end end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb index cbd31fd252e..b56a3ee43ae 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/user_select_list_component.rb @@ -33,22 +33,35 @@ module OpenProject module InplaceEditFields module DisplayFields class UserSelectListComponent < SelectListComponent - private + include CustomFieldsHelper - def render_custom_field_display_value - users = custom_field_values.filter_map(&:typed_value) - return t("placeholders.default") if users.empty? + attr_reader :model, :attribute, :writable - if custom_field.multi_value? - flex_layout do |avatar_container| - users.each { |user| avatar_container.with_row { render_avatar(user) } } - end - else - render_avatar(users.first) - end + def formatted_custom_field_values + return @formatted_custom_field_values if defined?(@formatted_custom_field_values) + + cf_values = custom_field_values + + users = cf_values.filter_map(&:typed_value) + + @formatted_custom_field_values = if custom_field.multi_value? + flex_layout do |avatar_container| + users.each do |user| + avatar_container.with_row do + render_avatar(user) + end + end + end + else + render_avatar(users.first) + end end + private + def render_avatar(user) + return unless user + render(::Users::AvatarComponent.new(user:, size: :mini)) end end diff --git a/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb b/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb index 1b8524f41e4..caeda6006f5 100644 --- a/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/hierarchy_list_component.rb @@ -34,6 +34,10 @@ module OpenProject class HierarchyListComponent < BaseFieldComponent include CustomFieldHierarchyTreeViewHelper + def self.display_class + DisplayFields::HierarchyListComponent + end + def self.open_in_dialog? true end diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb index 3ec1fc7dd0b..22d7125c5a3 100644 --- a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb +++ b/lookbook/docs/patterns/06-inplace-edit-fields.md.erb @@ -201,6 +201,10 @@ end class HierarchyListComponent < BaseFieldComponent include CustomFieldHierarchyTreeViewHelper + def self.display_class + DisplayFields::HierarchyListComponent + end + def self.open_in_dialog? true end diff --git a/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb b/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb new file mode 100644 index 00000000000..437a67fd8e9 --- /dev/null +++ b/spec/components/open_project/common/inplace_edit_fields/display_fields/hierarchy_list_component_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "rails_helper" + +RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::HierarchyListComponent, + type: :component, with_ee: [:custom_field_hierarchies] do + include ViewComponent::TestHelpers + + let(:project) { create(:project) } + let(:custom_field) { create(:project_custom_field, :hierarchy) } + let(:attribute) { custom_field.attribute_name.to_sym } + + it "renders a placeholder when no value is set" do + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text(I18n.t("placeholders.default")) + end + + it "renders the item label for a single hierarchy value" do + item = create(:hierarchy_item, label: "Alpha") + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item.id.to_s) + + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text("Alpha") + end + + context "with a multi-value hierarchy field" do + let(:custom_field) { create(:project_custom_field, :multi_hierarchy) } + + it "renders multiple item labels joined by comma" do + item1 = create(:hierarchy_item, label: "Alpha") + item2 = create(:hierarchy_item, label: "Beta") + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item1.id.to_s) + create(:custom_value, :skip_validations, customized: project, custom_field:, value: item2.id.to_s) + + render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false)) + + expect(rendered_content).to have_text("Alpha, Beta") + end + end +end From 219e1048e083f4126f76d56b9f12d2d95f61babc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 18 Mar 2026 09:40:22 +0100 Subject: [PATCH 311/435] Destroy synced groups when group is destroyed --- app/models/group.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/group.rb b/app/models/group.rb index c2e36c6f9ae..40b613afa4d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -63,6 +63,10 @@ class Group < Principal through: :group_users, before_add: :fail_add + has_many :synchronized_groups, + class_name: "::LdapGroups::SynchronizedGroup", + dependent: :destroy + acts_as_customizable alias_attribute(:name, :lastname) From 4d731dcab673c153098b72cbcf921b2eddb430b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 18 Feb 2026 07:47:11 +0100 Subject: [PATCH 312/435] Replace raw and explicit html_safe calls --- .../activities/item_component.html.erb | 5 +- app/components/activities/item_component.rb | 3 +- .../item_subtitle_component.html.erb | 7 +- .../enterprise_edition/plan_for_feature.rb | 4 +- .../trial_teaser_component.rb | 2 +- .../show_page_header_component.html.erb | 6 +- .../projects/row_component.html.erb | 9 +- app/components/projects/row_component.rb | 24 +++-- .../projects/table_component.html.erb | 7 +- app/components/row_component.html.erb | 9 +- .../project_work_packages/header_component.rb | 2 +- .../invite_user_form_component.html.erb | 8 +- app/components/users/hover_card_component.rb | 4 +- .../users/show_page_header_component.html.erb | 3 +- .../journals/revision_component.html.erb | 18 ++-- .../date_picker/banner_component.rb | 24 ++--- .../exports/pdf/export_settings_component.rb | 9 +- app/controllers/account_controller.rb | 2 +- .../concerns/accounts/user_limits.rb | 28 +++--- .../work_packages/reminders_controller.rb | 4 +- ...write_readonly_attributes_caption.html.erb | 6 +- .../apiv3_cors_origins_caption.html.erb | 6 +- .../allowed_link_protocols_caption.html.erb | 4 +- .../security_badge_displayed_caption.html.erb | 4 +- app/forms/application_form.rb | 2 + app/forms/enterprise_trials/form.rb | 11 ++- app/forms/my/look_and_feel_form.rb | 4 +- .../creation_wizard/submission_form.rb | 2 +- .../settings/work_packages/activities/form.rb | 12 +-- app/forms/scim_clients/form.rb | 2 +- .../settings/authentication_settings_form.rb | 4 +- app/forms/settings/form_helper.rb | 4 +- app/forms/statuses/form.rb | 6 +- app/helpers/accessibility_helper.rb | 15 ++- app/helpers/application_helper.rb | 33 +++++-- app/helpers/breadcrumb_helper.rb | 9 +- app/helpers/error_message_helper.rb | 4 +- .../flash_messages_output_safety_helper.rb | 10 +- app/helpers/hook_helper.rb | 5 +- app/helpers/icons_helper.rb | 5 +- app/helpers/oauth_helper.rb | 2 +- app/helpers/pagination_helper.rb | 2 +- app/helpers/password_helper.rb | 11 ++- app/helpers/repositories_helper.rb | 97 +++++++++---------- app/helpers/search_helper.rb | 33 ++++--- app/helpers/settings_helper.rb | 41 +++++--- app/helpers/text_formatting_helper.rb | 9 +- app/helpers/users_helper.rb | 9 +- app/helpers/versions_helper.rb | 4 +- app/helpers/work_packages_helper.rb | 18 +--- .../activities/project_activity_provider.rb | 2 +- app/views/account/exit.html.erb | 8 +- app/views/activities/index.html.erb | 8 +- app/views/admin/backups/reset_token.html.erb | 8 +- app/views/admin/info.html.erb | 16 ++- .../_passwords.html.erb | 14 ++- .../authentication_settings/_sso.html.erb | 4 +- app/views/members/_member_form.html.erb | 6 +- app/views/news/show.html.erb | 6 +- .../placeholder_users/deletion_info.html.erb | 12 +-- .../copy_project_failed.html.erb | 2 +- app/views/repositories/_properties.html.erb | 39 ++++++++ app/views/repositories/changes.html.erb | 2 +- app/views/repositories/destroy_info.html.erb | 38 +++++--- app/views/repositories/diff.html.erb | 13 +-- app/views/repositories/show.html.erb | 2 +- .../shared_work_package.html.erb | 6 +- .../shared_work_package.text.erb | 2 +- .../activation_limit_reached.html.erb | 2 +- .../activation_limit_reached.text.erb | 2 +- app/views/users/_preferences.html.erb | 16 ++- .../work_packages/bulk/reassign.html.erb | 14 ++- app/views/workflows/copy.html.erb | 19 ++-- config/locales/en.yml | 88 ++++++++++------- config/locales/js-en.yml | 2 - config/static_links.yml | 2 + lib/custom_field_form_builder.rb | 13 ++- lib/open_project/object_linking.rb | 6 +- lib/open_project/page_hierarchy_helper.rb | 2 +- .../patches/action_view_accessible_errors.rb | 6 +- lib/redmine/menu_manager/menu_helper.rb | 10 +- lib/tabular_form_builder.rb | 4 +- lib_static/redmine/i18n.rb | 2 +- lookbook/docs/patterns/02-forms.md.erb | 8 +- .../saml/providers/confirm_destroy.html.erb | 5 +- .../views/avatars/users/_gravatars.html.erb | 2 +- .../job_status/dialog/body_component.html.erb | 2 +- .../destroy_info.html.erb | 6 +- .../synchronized_groups/destroy_info.html.erb | 4 +- modules/ldap_groups/config/locales/en.yml | 4 +- .../app/components/base_errors_component.rb | 2 +- .../app/controllers/meetings_controller.rb | 50 +++++----- .../recurring_meetings_controller.rb | 2 +- .../providers/confirm_destroy.html.erb | 6 +- modules/openid_connect/config/locales/en.yml | 4 +- .../app/views/recaptcha/admin/show.html.erb | 4 +- ...h_application_info_copy_component.html.erb | 6 +- .../oauth_application_info_copy_component.rb | 10 -- ...roy_confirmation_dialog_component.html.erb | 3 +- ...roy_confirmation_dialog_component.html.erb | 3 +- .../project_storage_members/index.html.erb | 4 +- modules/storages/config/locales/en.yml | 9 +- .../authentication/request_otp.html.erb | 4 +- .../_two_factor_authentication_self.html.erb | 4 +- .../config/locales/en.yml | 7 +- .../webhooks/outgoing/admin/index.html.erb | 6 +- modules/webhooks/config/locales/en.yml | 3 +- spec/features/auth/omniauth_spec.rb | 10 +- spec/helpers/error_message_helper_spec.rb | 10 +- spec/helpers/text_formatting_helper_spec.rb | 2 +- spec/lib/tabular_form_builder_spec.rb | 12 ++- 111 files changed, 607 insertions(+), 513 deletions(-) create mode 100644 app/views/repositories/_properties.html.erb diff --git a/app/components/activities/item_component.html.erb b/app/components/activities/item_component.html.erb index 00520787386..121c89daf3b 100644 --- a/app/components/activities/item_component.html.erb +++ b/app/components/activities/item_component.html.erb @@ -34,7 +34,10 @@ See COPYRIGHT and LICENSE files for more details. <% else %> <%= @event.event_title %> <% end %> - <%= project_suffix %> + <% suffix = project_suffix %> + <% if suffix.present? %> + (<%= suffix %>) + <% end %>
    <%= render( diff --git a/app/components/activities/item_component.rb b/app/components/activities/item_component.rb index 803fead8871..923e7415169 100644 --- a/app/components/activities/item_component.rb +++ b/app/components/activities/item_component.rb @@ -45,8 +45,7 @@ class Activities::ItemComponent < ViewComponent::Base return if activity_is_from_current_project? kind = activity_is_from_subproject? ? "subproject" : "project" - suffix = I18n.t("events.title.#{kind}", name: link_to(@event.project.name, @event.project)) - "(#{suffix})".html_safe # rubocop:disable Rails/OutputSafety + helpers.t("events.title.#{kind}_html", name: link_to(@event.project.name, @event.project)) end def display_user? diff --git a/app/components/activities/item_subtitle_component.html.erb b/app/components/activities/item_subtitle_component.html.erb index 4721b1d0aa2..02b65f3805d 100644 --- a/app/components/activities/item_subtitle_component.html.erb +++ b/app/components/activities/item_subtitle_component.html.erb @@ -29,10 +29,11 @@ See COPYRIGHT and LICENSE files for more details.
    <%= + # OG: html_safe usage has been double-checked and would otherwise require a lot of i18n key change I18n.t( i18n_key, - user: user_html, - datetime: datetime_html - ).html_safe # OG: html_safe usage has been double-checked and would otherwise require a lot of i18n key change + user: h(user_html), + datetime: h(datetime_html) + ).html_safe # rubocop:disable Rails/OutputSafety %>
    diff --git a/app/components/enterprise_edition/plan_for_feature.rb b/app/components/enterprise_edition/plan_for_feature.rb index 64d2bee683f..747708b6953 100644 --- a/app/components/enterprise_edition/plan_for_feature.rb +++ b/app/components/enterprise_edition/plan_for_feature.rb @@ -48,7 +48,7 @@ module EnterpriseEdition def description @description || begin if I18n.exists?(:description_html, scope: i18n_scope) - I18n.t(:description_html, scope: i18n_scope).html_safe + helpers.t(:description_html, scope: i18n_scope) else I18n.t(:description, scope: i18n_scope) end @@ -85,7 +85,7 @@ module EnterpriseEdition I18n.t("ee.upsell.plan_name", plan: plan.capitalize) end - I18n.t("ee.upsell.plan_text_html", plan_name:).html_safe + helpers.t("ee.upsell.plan_text_html", plan_name:) end end end diff --git a/app/components/enterprise_edition/trial_teaser_component.rb b/app/components/enterprise_edition/trial_teaser_component.rb index 016be7929e1..3af8233fa05 100644 --- a/app/components/enterprise_edition/trial_teaser_component.rb +++ b/app/components/enterprise_edition/trial_teaser_component.rb @@ -65,7 +65,7 @@ module EnterpriseEdition end def description - I18n.t("ee.teaser.description", trial_plan: plan_name).html_safe + helpers.t("ee.teaser.description_html", trial_plan: plan_name) end def plan_name diff --git a/app/components/placeholder_users/show_page_header_component.html.erb b/app/components/placeholder_users/show_page_header_component.html.erb index 80afe8f33a9..c04ab856621 100644 --- a/app/components/placeholder_users/show_page_header_component.html.erb +++ b/app/components/placeholder_users/show_page_header_component.html.erb @@ -28,7 +28,11 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { "#{avatar @placeholder_user} #{h(@placeholder_user.name)}".html_safe } + header.with_title do + concat avatar(@placeholder_user) + concat @placeholder_user.name + end + header.with_breadcrumbs(breadcrumb_items) if @current_user.allowed_globally?(:manage_placeholder_user) diff --git a/app/components/projects/row_component.html.erb b/app/components/projects/row_component.html.erb index cff17907e37..d92127cfd5e 100644 --- a/app/components/projects/row_component.html.erb +++ b/app/components/projects/row_component.html.erb @@ -27,9 +27,10 @@ See COPYRIGHT and LICENSE files for more details. ++#%> - - <%= "class=\"#{row_css_class}\"".html_safe if row_css_class %>> +<%= content_tag(:tr, + id: row_css_id, + class: row_css_class + ) do %> <% columns.each do |column| %> <%= column_value(column) %> @@ -40,4 +41,4 @@ See COPYRIGHT and LICENSE files for more details. <%= link %> <% end %> - +<% end %> diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 9d81d167966..e09c722ee59 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -211,17 +211,15 @@ module Projects def project_status return nil unless user_can_view_project_attributes? - content = "".html_safe - status_code = project.status_code - if status_code classes = helpers.project_status_css_class(status_code) - content << content_tag(:span, "", class: "project-status--bulb -inline #{classes}") - content << content_tag(:span, helpers.project_status_name(status_code), class: "project-status--name #{classes}") - end - content + capture do + concat content_tag(:span, "", class: "project-status--bulb -inline #{classes}") + concat content_tag(:span, helpers.project_status_name(status_code), class: "project-status--name #{classes}") + end + end end def status_explanation @@ -250,7 +248,7 @@ module Projects def row_css_class classes = %w[basics context-menu--reveal op-project-row-component] - classes << project_css_classes + classes += project_css_classes classes << row_css_level_classes classes.join(" ") @@ -269,13 +267,13 @@ module Projects end def project_css_classes - s = " project ".html_safe + output = ["project"] - s << " root" if project.root? - s << " child" if project.child? - s << (project.leaf? ? " leaf" : " parent") + output << "root" if project.root? + output << "child" if project.child? + output << (project.leaf? ? "leaf" : "parent") - s + output end def column_css_class(column) diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index ca4087a80e9..0de960bd03b 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -31,7 +31,10 @@ See COPYRIGHT and LICENSE files for more details.
    - > + <%= content_tag :table, + id: table_id, + class: "generic-table", + data: { controller: "table-highlighting" } do %> <% columns.each do |column| %> > @@ -84,7 +87,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <%= render_collection rows %> -
    + <% end %> <% if inline_create_link && show_inline_create %>
    <%= inline_create_link %> diff --git a/app/components/row_component.html.erb b/app/components/row_component.html.erb index cff17907e37..592f4cbb23a 100644 --- a/app/components/row_component.html.erb +++ b/app/components/row_component.html.erb @@ -27,9 +27,10 @@ See COPYRIGHT and LICENSE files for more details. ++#%> - - <%= "class=\"#{row_css_class}\"".html_safe if row_css_class %>> +<%= content_tag(:tr, + id: row_css_id, + class: row_css_class + ) do %> <% columns.each do |column| %> <%= column_value(column) %> @@ -40,4 +41,4 @@ See COPYRIGHT and LICENSE files for more details. <%= link %> <% end %> - +<% end %> diff --git a/app/components/settings/project_work_packages/header_component.rb b/app/components/settings/project_work_packages/header_component.rb index abc9509c897..ff0a0d43137 100644 --- a/app/components/settings/project_work_packages/header_component.rb +++ b/app/components/settings/project_work_packages/header_component.rb @@ -104,7 +104,7 @@ module Settings private - def internal_comments_translation = t("ee.features.internal_comments").html_safe + def internal_comments_translation = t("ee.features.internal_comments") end end end diff --git a/app/components/shares/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb index 9ad2d42ef4b..4996d652d5e 100644 --- a/app/components/shares/invite_user_form_component.html.erb +++ b/app/components/shares/invite_user_form_component.html.erb @@ -39,11 +39,11 @@ user_limit_row.with_column do render(Primer::Beta::Text.new(color: :danger)) do - I18n.t( + helpers.link_translate( "sharing.warning_user_limit_reached#{'_admin' if User.current.admin?}", - upgrade_url: OpenProject::Enterprise.upgrade_url, - entity: entity.model_name.human - ).html_safe + i18n_args: { entity: entity.model_name.human }, + links: { upgrade_url: OpenProject::Enterprise.upgrade_url } + ) end end end diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index 7b04ba87313..014639f1851 100644 --- a/app/components/users/hover_card_component.rb +++ b/app/components/users/hover_card_component.rb @@ -90,9 +90,9 @@ class Users::HoverCardComponent < ApplicationComponent remaining_count_link = link_to(t("users.groups.more", count: remaining_count), user_path(@user)) if remaining_count > 0 - t("users.groups.summary_with_more", names: summary_links, count_link: remaining_count_link).html_safe + t("users.groups.summary_with_more_html", names: summary_links, count_link: remaining_count_link) else - t("users.groups.summary", names: summary_links).html_safe + t("users.groups.summary_html", names: summary_links) end end end diff --git a/app/components/users/show_page_header_component.html.erb b/app/components/users/show_page_header_component.html.erb index 85dc0bb68ee..231ba6e2102 100644 --- a/app/components/users/show_page_header_component.html.erb +++ b/app/components/users/show_page_header_component.html.erb @@ -29,7 +29,8 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title do - "#{avatar(@user, hover_card: { active: false })} #{h(@user.name)}".html_safe + concat avatar(@user, hover_card: { active: false }) + concat @user.name end header.with_breadcrumbs(breadcrumb_items) diff --git a/app/components/work_packages/activities_tab/journals/revision_component.html.erb b/app/components/work_packages/activities_tab/journals/revision_component.html.erb index ad622092be3..91c78d38890 100644 --- a/app/components/work_packages/activities_tab/journals/revision_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/revision_component.html.erb @@ -42,13 +42,13 @@ target: "_blank" ) ) do - I18n.t("js.label_committed_link", revision_identifier: short_revision) + I18n.t(:label_committed_link, revision_identifier: short_revision) end I18n.t( - "js.label_committed_at", - committed_revision_link: committed_text.html_safe, + :label_committed_at_html, + committed_revision_link: committed_text, date: format_time(changeset.committed_on) - ).html_safe + ) end end end @@ -69,13 +69,13 @@ target: "_blank" ) ) do - I18n.t("js.label_committed_link", revision_identifier: short_revision) + I18n.t(:label_committed_link, revision_identifier: short_revision) end - I18n.t( - "js.label_committed_at", - committed_revision_link: committed_text.html_safe, + t( + :label_committed_at_html, + committed_revision_link: committed_text, date: format_time(changeset.committed_on) - ).html_safe + ) end end end diff --git a/app/components/work_packages/date_picker/banner_component.rb b/app/components/work_packages/date_picker/banner_component.rb index 8ece0b374a1..89a2c999926 100644 --- a/app/components/work_packages/date_picker/banner_component.rb +++ b/app/components/work_packages/date_picker/banner_component.rb @@ -96,15 +96,17 @@ module WorkPackages end def mobile_description - text = if @manually_scheduled - I18n.t("work_packages.datepicker_modal.banner.description.manual_mobile") - else - I18n.t("work_packages.datepicker_modal.banner.description.automatic_mobile") - end + text = + if @manually_scheduled + I18n.t("work_packages.datepicker_modal.banner.description.manual_mobile") + else + I18n.t("work_packages.datepicker_modal.banner.description.automatic_mobile") + end - "#{text} #{render(Primer::Beta::Link.new(tag: :a, href: link, target: '_blank', underline: true)) do - I18n.t('work_packages.datepicker_modal.show_relations') - end}".html_safe + capture do + concat text + concat render(Primer::Beta::Link.new(tag: :a, href: link, target: "_blank", underline: true)) { I18n.t("work_packages.datepicker_modal.show_relations") } + end end def overlapping_predecessor? @@ -122,7 +124,7 @@ module WorkPackages predecessor_work_packages.filter_map(&:due_date) .max - &.before?(@work_package.start_date - 2) + &.before?(@work_package.start_date - 2) end def predecessor_relations @@ -131,8 +133,8 @@ module WorkPackages def predecessor_work_packages @predecessor_work_packages ||= predecessor_relations - .includes(:to) - .map(&:to) + .includes(:to) + .map(&:to) end def children diff --git a/app/components/work_packages/exports/pdf/export_settings_component.rb b/app/components/work_packages/exports/pdf/export_settings_component.rb index 1d718420f0d..d7f549749e0 100644 --- a/app/components/work_packages/exports/pdf/export_settings_component.rb +++ b/app/components/work_packages/exports/pdf/export_settings_component.rb @@ -49,8 +49,13 @@ module WorkPackages end def gantt_chart_label - label = I18n.t("export.dialog.pdf.export_type.options.gantt.label") - gantt_chart_allowed? ? label : (label + enterprise_icon).html_safe # rubocop:disable Rails/OutputSafety + capture do + concat I18n.t("export.dialog.pdf.export_type.options.gantt.label") + + unless gantt_chart_allowed? + concat enterprise_icon + end + end end def pdf_export_types diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb index 9d800c40375..50507eda3f0 100644 --- a/app/controllers/account_controller.rb +++ b/app/controllers/account_controller.rb @@ -283,7 +283,7 @@ class AccountController < ApplicationController ldap_auth_source_id: user.ldap_auth_source_id } - flash[:notice] = I18n.t("account.auth_source_login", login: user.login).html_safe + flash[:notice] = helpers.t("account.auth_source_login_html", login: user.login) redirect_to signin_path(username: user.login) end diff --git a/app/controllers/concerns/accounts/user_limits.rb b/app/controllers/concerns/accounts/user_limits.rb index 88a9ebea0ad..9814cc68337 100644 --- a/app/controllers/concerns/accounts/user_limits.rb +++ b/app/controllers/concerns/accounts/user_limits.rb @@ -91,18 +91,16 @@ module Accounts::UserLimits end def user_limit_warning - warning = if current_user.admin? - I18n.t( - :warning_user_limit_reached_admin, - upgrade_url: OpenProject::Enterprise.upgrade_url - ) - else - I18n.t( - :warning_user_limit_reached - ) - end - - warning.html_safe + if current_user.admin? + link_translate( + :warning_user_limit_reached_admin, + links: { upgrade_url: OpenProject::Enterprise.upgrade_url } + ) + else + I18n.t( + :warning_user_limit_reached + ) + end end def show_imminent_user_limit_warning!(flash_now: false) @@ -115,12 +113,10 @@ module Accounts::UserLimits # A warning for when the user limit has technically not been reached yet # but if all invited users were to activate their accounts it would be reached. def imminent_user_limit_warning - warning = I18n.t( + link_translate( :warning_imminent_user_limit, - upgrade_url: OpenProject::Enterprise.upgrade_url + links: { upgrade_url: OpenProject::Enterprise.upgrade_url } ) - - warning.html_safe end def user_limit_reached? diff --git a/app/controllers/work_packages/reminders_controller.rb b/app/controllers/work_packages/reminders_controller.rb index 8289236e51a..4d6bc949737 100644 --- a/app/controllers/work_packages/reminders_controller.rb +++ b/app/controllers/work_packages/reminders_controller.rb @@ -53,8 +53,8 @@ class WorkPackages::RemindersController < ApplicationController .call(reminder_params) if service_result.success? - message = I18n.t("work_package.reminders.create_success_message", - reminder_time: reminder_chosen_time(service_result.result)).html_safe + message = helpers.t("work_package.reminders.create_success_message_html", + reminder_time: reminder_chosen_time(service_result.result)) respond_with_success_flash_message(message:) else respond_with_error_modal_component(service_result) diff --git a/app/forms/admin/settings/api_settings_form/apiv3_write_readonly_attributes_caption.html.erb b/app/forms/admin/settings/api_settings_form/apiv3_write_readonly_attributes_caption.html.erb index 29f7e219749..e97978f18c7 100644 --- a/app/forms/admin/settings/api_settings_form/apiv3_write_readonly_attributes_caption.html.erb +++ b/app/forms/admin/settings/api_settings_form/apiv3_write_readonly_attributes_caption.html.erb @@ -9,9 +9,9 @@ <% end %> <%= render(Primer::Beta::Text.new(tag: :p, mb: 0)) do %> <%= - I18n.t( + helpers.link_translate( :setting_apiv3_write_readonly_attributes_additional, - api_documentation_link: static_link_to(:api_docs) - ).html_safe + links: { api_documentation_link: %i[api_docs] } + ) %> <% end %> diff --git a/app/forms/admin/settings/cors_form/apiv3_cors_origins_caption.html.erb b/app/forms/admin/settings/cors_form/apiv3_cors_origins_caption.html.erb index 6f35bc4993b..76c3f9742ab 100644 --- a/app/forms/admin/settings/cors_form/apiv3_cors_origins_caption.html.erb +++ b/app/forms/admin/settings/cors_form/apiv3_cors_origins_caption.html.erb @@ -3,8 +3,8 @@ <% end %> <%= render(Primer::Beta::Text.new(tag: :p)) do %> - <%= I18n.t( + <%= helpers.link_translate( :setting_apiv3_cors_origins_text_html, - origin_link: ::OpenProject::Static::Links.url_for(:origin_mdn_documentation) - ).html_safe %> + links: { docs_url: %i[origin_mdn_documentation] } + ) %> <% end %> diff --git a/app/forms/admin/settings/general_settings_form/allowed_link_protocols_caption.html.erb b/app/forms/admin/settings/general_settings_form/allowed_link_protocols_caption.html.erb index 1c785c65cd7..8984de8980b 100644 --- a/app/forms/admin/settings/general_settings_form/allowed_link_protocols_caption.html.erb +++ b/app/forms/admin/settings/general_settings_form/allowed_link_protocols_caption.html.erb @@ -1,10 +1,10 @@ <%= - I18n.t( + helpers.t( :setting_allowed_link_protocols_text_html, tel_code: content_tag(:code, "tel"), element_code: content_tag(:code, "element"), http_code: content_tag(:code, "http"), https_code: content_tag(:code, "https"), mailto_code: content_tag(:code, "mailto") - ).html_safe + ) %> diff --git a/app/forms/admin/settings/general_settings_form/security_badge_displayed_caption.html.erb b/app/forms/admin/settings/general_settings_form/security_badge_displayed_caption.html.erb index 887af818d17..e8c3d883ceb 100644 --- a/app/forms/admin/settings/general_settings_form/security_badge_displayed_caption.html.erb +++ b/app/forms/admin/settings/general_settings_form/security_badge_displayed_caption.html.erb @@ -1,8 +1,8 @@ <%= - I18n.t( + helpers.t( :text_notice_security_badge_displayed_html, information_panel_label: I18n.t(:label_information), more_info_url: ::OpenProject::Static::Links.url_for(:security_badge_documentation), information_panel_path: url_helpers.info_admin_index_path - ).html_safe + ) %> diff --git a/app/forms/application_form.rb b/app/forms/application_form.rb index c0e9938ecda..f74271ccbdc 100644 --- a/app/forms/application_form.rb +++ b/app/forms/application_form.rb @@ -37,6 +37,8 @@ class ApplicationForm < Primer::Forms::Base end end + delegate :helpers, to: :ApplicationController + def url_helpers Rails.application.routes.url_helpers end diff --git a/app/forms/enterprise_trials/form.rb b/app/forms/enterprise_trials/form.rb index a721f0eb0a8..ca34865d357 100644 --- a/app/forms/enterprise_trials/form.rb +++ b/app/forms/enterprise_trials/form.rb @@ -67,13 +67,20 @@ module EnterpriseTrials f.check_box( required: true, - label: I18n.t("ee.trial.consent_html").html_safe, + label: helpers.link_translate("ee.trial.consent", + links: { + tos_url: %i[terms_of_service], + privacy_url: %i[data_privacy] + }), name: :general_consent ) f.check_box( required: false, - label: I18n.t("ee.trial.receive_newsletter_html").html_safe, + label: helpers.link_translate("ee.trial.receive_newsletter", + links: { + newsletter_url: %i[newsletter] + }), name: :newsletter_consent ) end diff --git a/app/forms/my/look_and_feel_form.rb b/app/forms/my/look_and_feel_form.rb index f28a2312326..5d3904d930c 100644 --- a/app/forms/my/look_and_feel_form.rb +++ b/app/forms/my/look_and_feel_form.rb @@ -84,7 +84,7 @@ class My::LookAndFeelForm < ApplicationForm private def disable_keyboard_shortcuts_caption - attribute_name(:disable_keyboard_shortcuts_caption_html, - href: OpenProject::Static::Links.url_for(:shortcuts)).html_safe # rubocop:disable Rails/OutputSafety + helpers.link_translate(:"user_preferences.disable_keyboard_shortcuts_caption", + links: { docs_url: %i[shortcuts] }) end end diff --git a/app/forms/projects/settings/creation_wizard/submission_form.rb b/app/forms/projects/settings/creation_wizard/submission_form.rb index 655e9369b02..abba1d9013d 100644 --- a/app/forms/projects/settings/creation_wizard/submission_form.rb +++ b/app/forms/projects/settings/creation_wizard/submission_form.rb @@ -77,7 +77,7 @@ module Projects f.autocompleter( name: :project_creation_wizard_assignee_custom_field_id, label: I18n.t("settings.project_initiation_request.submission.assignee"), - caption: I18n.t("settings.project_initiation_request.submission.assignee_caption_html").html_safe, + caption: helpers.t("settings.project_initiation_request.submission.assignee_caption_html"), required: false, input_width: :large, autocomplete_options: { diff --git a/app/forms/projects/settings/work_packages/activities/form.rb b/app/forms/projects/settings/work_packages/activities/form.rb index 989253fd5d5..a78c73f459b 100644 --- a/app/forms/projects/settings/work_packages/activities/form.rb +++ b/app/forms/projects/settings/work_packages/activities/form.rb @@ -51,16 +51,8 @@ module Projects::Settings::WorkPackages::Activities private def caption_text - link = render( - Primer::Beta::Link.new( - href: OpenProject::Static::Links.url_for(:enterprise_features, :internal_comments), - underline: true - ) - ) do - I18n.t("label_learn_more") - end - - I18n.t("settings.work_packages.activities.helper_text", link:).html_safe + helpers.link_translate("settings.work_packages.activities.helper_text", + links: { docs_url: %i[enterprise_features internal_comments] }) end end end diff --git a/app/forms/scim_clients/form.rb b/app/forms/scim_clients/form.rb index f88d77f5194..c9c6f3e8833 100644 --- a/app/forms/scim_clients/form.rb +++ b/app/forms/scim_clients/form.rb @@ -59,7 +59,7 @@ module ScimClients client_form.select_list( name: :authentication_method, label: ScimClient.human_attribute_name(:authentication_method), - caption: I18n.t("admin.scim_clients.form.authentication_method_description_html").html_safe, + caption: helpers.t("admin.scim_clients.form.authentication_method_description_html"), input_width: :large, include_blank: false, disabled: model.persisted?, diff --git a/app/forms/settings/authentication_settings_form.rb b/app/forms/settings/authentication_settings_form.rb index 31d60214ab5..554e6312f75 100644 --- a/app/forms/settings/authentication_settings_form.rb +++ b/app/forms/settings/authentication_settings_form.rb @@ -91,13 +91,13 @@ module Settings f.text_field( name: :after_first_login_redirect_url, - caption: I18n.t(:setting_after_first_login_redirect_url_text_html).html_safe, + caption: helpers.t(:setting_after_first_login_redirect_url_text_html), input_width: :large ) f.text_field( name: :after_login_default_redirect_url, - caption: I18n.t(:setting_after_login_default_redirect_url_text_html).html_safe, + caption: helpers.t(:setting_after_login_default_redirect_url_text_html), input_width: :large ) diff --git a/app/forms/settings/form_helper.rb b/app/forms/settings/form_helper.rb index c203e84443e..10aefadb59d 100644 --- a/app/forms/settings/form_helper.rb +++ b/app/forms/settings/form_helper.rb @@ -56,8 +56,8 @@ module Settings # @param names [Array] The name(s) of the setting # @return [String] The translated HTML-safe caption def setting_caption(*names) - I18n.t("setting_#{names.join('_')}_caption_html", default: nil)&.html_safe \ - || I18n.t("setting_#{names.join('_')}_caption", default: nil) + ApplicationController.helpers.t("setting_#{names.join('_')}_caption_html", default: nil) || + I18n.t("setting_#{names.join('_')}_caption", default: nil) end # Retrieves the current value of a setting diff --git a/app/forms/statuses/form.rb b/app/forms/statuses/form.rb index cac0631dde9..d6550440bdf 100644 --- a/app/forms/statuses/form.rb +++ b/app/forms/statuses/form.rb @@ -79,7 +79,7 @@ module Statuses label: attribute_name(:is_readonly), name: :is_readonly, disabled: readonly_disabled?, - caption: I18n.t("statuses.edit.status_readonly_html").html_safe, + caption: helpers.t("statuses.edit.status_readonly_html"), data: { "admin--statuses-target": "isReadonlyCheckbox", restricted: readonly_work_packages_restricted? @@ -115,8 +115,8 @@ module Statuses end def percent_complete_field_caption - I18n.t("statuses.edit.status_percent_complete_text", - href: url_helpers.admin_settings_progress_tracking_path).html_safe + helpers.link_translate("statuses.edit.status_percent_complete_text", + links: { setting_url: url_helpers.admin_settings_progress_tracking_path }) end def already_default_status? diff --git a/app/helpers/accessibility_helper.rb b/app/helpers/accessibility_helper.rb index f363d62284a..f19ae4eef99 100644 --- a/app/helpers/accessibility_helper.rb +++ b/app/helpers/accessibility_helper.rb @@ -30,17 +30,16 @@ module AccessibilityHelper def you_are_here_info(condition = true, disabled = nil) - if condition && !disabled - "#{I18n.t(:description_current_position)}".html_safe - elsif condition && disabled - "".html_safe - else - "" - end + return "" unless condition + + content_tag(:span, + I18n.t(:description_current_position), + style: disabled ? "display: none" : "display: block", + class: "position-label sr-only") end def empty_element_tag - @empty_element_tag ||= ApplicationController.new.render_to_string(partial: "accessibility/empty_element_tag").html_safe + @empty_element_tag ||= ApplicationController.new.render_to_string(partial: "accessibility/empty_element_tag") end # Returns the locale :en for the given menu item if the user locale is diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 588b4668c23..9f7dbb97ed1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -130,8 +130,8 @@ module ApplicationHelper class: :user_status_class) end - def labeled_check_box_tags(name, collection, options = {}) - collection.sort.map do |object| + def labeled_check_box_tags(name, collection, options = {}) # rubocop:disable Metrics/AbcSize + fields = collection.sort.map do |object| id = name.gsub(/[\[\]]+/, "_") + object.id.to_s object_options = options.inject({}) do |h, (k, v)| @@ -146,18 +146,30 @@ module ApplicationHelper styled_check_box_tag(name, object.id, false, id:) + object.to_s end end - end.join.html_safe + end + + safe_join(fields) end def authoring(created, author, options = {}) label = options[:label] || :label_added_time_by - I18n.t(label, author: link_to_user(author), age: time_tag(created)).html_safe + # Ensure we pass inputs here to html_escape + # which will respect html_safe? + author = ERB::Util.html_escape link_to_user(author) + age = ERB::Util.html_escape time_tag(created) + + # OG: html_safe is used here with explicitly escaped inputs except for the translation file + I18n.t(label, author:, age:).html_safe end def authoring_at(creation_date, author) return if author.nil? - I18n.t(:label_added_by_on, author: link_to_user(author), date: creation_date).html_safe + author = ERB::Util.html_escape link_to_user(author) + date = ERB::Util.html_escape creation_date + + # OG: html_safe is used here to avoid having to change this reusable key + I18n.t(:label_added_by_on, author:, date:).html_safe end def time_tag(time) @@ -196,7 +208,8 @@ module ApplicationHelper formats = capture(Redmine::Views::OtherFormatsBuilder.new(self), &) unless formats.nil? || formats.strip.empty? content_tag "p", class: "other-formats" do - (I18n.t(:label_export_to) + formats).html_safe + concat I18n.t(:label_export_to) + concat formats end end end @@ -251,6 +264,12 @@ module ApplicationHelper .sort_by(&:first) end + def blank_select_option + content_tag(:option, + "--- #{t(:actionview_instancetag_blank_option)} ---", + disabled: true) + end + def theme_options_for_select [ [I18n.t("themes.light"), "light"], @@ -424,7 +443,7 @@ module ApplicationHelper # @param [optional, String] content the content of the ROBOTS tag. # defaults to no index, follow, and no archive def robot_exclusion_tag(content = "NOINDEX,FOLLOW,NOARCHIVE") - "".html_safe + content_tag(:meta, name: "ROBOTS", content:) end def permitted_params diff --git a/app/helpers/breadcrumb_helper.rb b/app/helpers/breadcrumb_helper.rb index 1c1ed2d468e..139ad6809b8 100644 --- a/app/helpers/breadcrumb_helper.rb +++ b/app/helpers/breadcrumb_helper.rb @@ -30,10 +30,9 @@ module BreadcrumbHelper def nested_breadcrumb_element(section_header, title) - output = "".html_safe - output << "#{section_header}: " - output << content_tag(:b, title) - - output + capture do + concat "#{section_header}: " + concat content_tag(:b, title) + end end end diff --git a/app/helpers/error_message_helper.rb b/app/helpers/error_message_helper.rb index 01aa37297ce..63601b3d3bd 100644 --- a/app/helpers/error_message_helper.rb +++ b/app/helpers/error_message_helper.rb @@ -63,7 +63,7 @@ module ErrorMessageHelper list_of_messages(base_error_messages), text_header_invalid_fields(base_error_messages, fields_error_messages), list_of_messages(fields_error_messages) - ].compact, "
    ".html_safe) + ].compact, tag(:br)) end def text_header_invalid_fields(base_error_messages, fields_error_messages) @@ -77,6 +77,6 @@ module ErrorMessageHelper def list_of_messages(messages) return if messages.blank? - safe_join(messages, "
    ".html_safe) + safe_join(messages, tag(:br)) end end diff --git a/app/helpers/flash_messages_output_safety_helper.rb b/app/helpers/flash_messages_output_safety_helper.rb index af7ef4dce9b..3bbc7eb87d9 100644 --- a/app/helpers/flash_messages_output_safety_helper.rb +++ b/app/helpers/flash_messages_output_safety_helper.rb @@ -32,17 +32,15 @@ module FlashMessagesOutputSafetyHelper extend ActiveSupport::Concern - included do - # For .safe_join in join_flash_messages - include ActionView::Helpers::OutputSafetyHelper - end - ### # Joins individual flash messages with a Line Break Element. # # @param [String|Array] messages the flash messages to join. # @return [String] the joined messages as an HTML-safe string. def join_flash_messages(messages) - safe_join(Array(messages), "
    ".html_safe) + ApplicationController.helpers.safe_join( + Array(messages), + ApplicationController.helpers.tag(:br) + ) end end diff --git a/app/helpers/hook_helper.rb b/app/helpers/hook_helper.rb index 3449d862fe3..d13aa698013 100644 --- a/app/helpers/hook_helper.rb +++ b/app/helpers/hook_helper.rb @@ -57,7 +57,10 @@ module HookHelper default_context = { project: @project, hook_caller: self } default_context[:controller] = controller if respond_to?(:controller) default_context[:request] = request if respond_to?(:request) - OpenProject::Hook.call_hook(hook, default_context.merge(context)).join(" ").html_safe + ApplicationController.helpers.safe_join( + OpenProject::Hook.call_hook(hook, default_context.merge(context)), + " " + ) end end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 410f2d77a96..00d9117c2b0 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -30,12 +30,11 @@ module IconsHelper ## - # Create an tag with the given icon class names + # Create a tag with the given icon class names # and make it aria-hidden since screenreaders otherwise # output the css `content` of the icon. def op_icon(classnames, title: nil) - title = "title=\"#{h(title)}\"" unless title.nil? - %().html_safe + content_tag(:i, nil, class: classnames, title:, "aria-hidden": true) end ## diff --git a/app/helpers/oauth_helper.rb b/app/helpers/oauth_helper.rb index f247e74c0d4..debabe77538 100644 --- a/app/helpers/oauth_helper.rb +++ b/app/helpers/oauth_helper.rb @@ -37,7 +37,7 @@ module OAuthHelper if strings.empty? I18n.t("oauth.scopes.api_v3") else - safe_join(strings.map { |scope| I18n.t("oauth.scopes.#{scope}", default: scope) }, "
    ".html_safe) + safe_join(strings.map { |scope| I18n.t("oauth.scopes.#{scope}", default: scope) }, tag(:br)) end end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index eaa03cd4796..808b6bc1d54 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -121,7 +121,7 @@ module PaginationHelper def pagination_options_section(paginator, params:, allowed_params:) per_page_options = Setting.per_page_options_array - return "".html_safe if per_page_options.empty? + return "" if per_page_options.empty? allowed_params ||= %w[filters sortBy] content_tag(:div, class: "op-pagination--options") do diff --git a/app/helpers/password_helper.rb b/app/helpers/password_helper.rb index fc14f525b8a..9f4c4112025 100644 --- a/app/helpers/password_helper.rb +++ b/app/helpers/password_helper.rb @@ -69,10 +69,13 @@ module PasswordHelper def render_password_complexity_hint rules = password_rules_description - s = OpenProject::Passwords::Evaluator.min_length_description - s += "
    #{rules}" if rules.present? - - s.html_safe + capture do + concat OpenProject::Passwords::Evaluator.min_length_description + if rules.present? + concat tag(:br) + concat rules + end + end end private diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb index 5e8343e159d..644feb9b452 100644 --- a/app/helpers/repositories_helper.rb +++ b/app/helpers/repositories_helper.rb @@ -47,16 +47,6 @@ module RepositoriesHelper text&.gsub(%r{^(.{#{length}}[^\n]*)\n.+$}m, '\\1...') end - def render_properties(properties) - unless properties.nil? || properties.empty? - content = +"" - properties.keys.sort.each do |property| - content << content_tag("li", raw("#{h property}: #{h properties[property]}")) - end - content_tag("ul", content.html_safe, class: "properties") - end - end - def render_changeset_changes changes = @changeset.file_changes.limit(1000).order(Arel.sql("path")).filter_map do |change| case change.action @@ -113,58 +103,59 @@ module RepositoriesHelper seen.size == 1 ? seen.first : :open end - def render_changes_tree(tree) - return "".html_safe if tree.nil? + # rubocop:disable Rails/HelperInstanceVariable + def render_changes_tree(tree) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity + return "" if tree.nil? - items = tree.keys.sort.flat_map do |file| - style = "change" + items = tree.keys.sort.filter_map do |file| text = File.basename(file) if (s = tree[file][:s]) - style += " folder" path_param = without_leading_slash(to_path_param(@repository.relative_path(file))) - text = link_to(h(text), + link = link_to(text, show_revisions_path_project_repository_path(project_id: @project, repo_path: path_param, rev: @changeset.identifier), title: I18n.t(:label_folder)) - folder_li = content_tag(:li, text, - class: "#{style} icon icon-folder-#{calculate_folder_action(s)}") - [folder_li, render_changes_tree(s)] + content_tag(:li, safe_join([link, render_changes_tree(s)]), + class: "change folder icon icon-folder-#{calculate_folder_action(s)}") elsif (c = tree[file][:c]) - style += " change-#{c.action}" path_param = without_leading_slash(to_path_param(@repository.relative_path(c.path))) + parts = [] - text_parts = [] + parts << + if c.action == "D" + text + else + link_to(text, + entry_revision_project_repository_path(project_id: @project, + repo_path: path_param, + rev: @changeset.identifier), + title: changes_tree_change_title(c.action)) + end - unless c.action == "D" - text = link_to(h(text), - entry_revision_project_repository_path(project_id: @project, - repo_path: path_param, - rev: @changeset.identifier), - title: changes_tree_change_title(c.action)) - end - - text_parts << text - text_parts << " - " << h(c.revision) if c.revision.present? + parts << safe_join([" - ", c.revision]) if c.revision.present? if c.action == "M" - text_parts << " (" << link_to(I18n.t(:label_diff), - diff_revision_project_repository_path(project_id: @project, - repo_path: path_param, - rev: @changeset.identifier)) << ") " + diff_link = link_to(I18n.t(:label_diff), + diff_revision_project_repository_path(project_id: @project, + repo_path: path_param, + rev: @changeset.identifier)) + parts << safe_join([" (", diff_link, ") "]) end - text_parts << " " << content_tag(:span, c.from_path, class: "copied-from") if c.from_path.present? + parts << safe_join([" ", content_tag(:span, c.from_path, class: "copied-from")]) if c.from_path.present? - [changes_tree_li_element(c.action, safe_join(text_parts), style)] + changes_tree_li_element(c.action, safe_join(parts), "change change-#{c.action}") end - end.compact + end content_tag(:ul, safe_join(items)) end + # rubocop:enable Rails/HelperInstanceVariable + def to_utf8_for_repositories(str) return str if str.nil? @@ -203,14 +194,16 @@ module RepositoriesHelper if str.respond_to?(:force_encoding) str.force_encoding("UTF-8") - if !str.valid_encoding? - str = str.encode("US-ASCII", invalid: :replace, - undef: :replace, replace: "?").encode("UTF-8") + unless str.valid_encoding? + str = str.encode("US-ASCII", + invalid: :replace, + undef: :replace, replace: "?") + .encode("UTF-8") end else # removes invalid UTF8 sequences begin - (str + " ").encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")[0..-3] + "#{str} ".encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")[0..-3] rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError end end @@ -297,17 +290,15 @@ module RepositoriesHelper end def changes_tree_li_element(action, text, style) - icon_name = case action - when "A" then "icon-add" - when "D" then "icon-delete" - when "C" then "icon-copy" - when "R" then "icon-rename" - else - "icon-arrow-left-right" - end + icon_name = + case action + when "A" then "icon-add" + when "D" then "icon-delete" + when "C" then "icon-copy" + when "R" then "icon-rename" + else "icon-arrow-left-right" + end - content_tag(:li, text, - class: "#{style} icon #{icon_name}", - title: changes_tree_change_title(action)) + content_tag(:li, text, class: "#{style} icon #{icon_name}", title: changes_tree_change_title(action)) end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index e833fe92e78..b4b97f37411 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -34,21 +34,27 @@ module SearchHelper return nil unless split_text.length > 1 || text_on_not_found - result = +"" + parts = [] + total_length = 0 + split_text.each_with_index do |words, i| - if result.length > 1200 + if total_length > 1200 # maximum length of the preview reached - result << "..." + parts << "..." break end - result << if i.even? - abbreviated_text(words) - else - token_span(tokens, words) - end + part = if i.even? + abbreviated_text(words) + else + token_span(tokens, words) + end + + parts << part + total_length += part.length end - result.html_safe + + safe_join(parts) end def highlight_tokens_in_event(event, tokens) @@ -66,12 +72,11 @@ module SearchHelper def highlight_and_abbreviate_html(event_description, tokens) html = OpenProject::TextFormatting::Renderer.format_text(event_description) highlighted_html = highlight_tokens_in_html(html, tokens) - # rubocop:disable Rails/OutputSafety - abbreviated_html(highlighted_html).html_safe - # rubocop:enable Rails/OutputSafety + # This html_safe call is fine, as coming from our html pipeline + abbreviated_html(highlighted_html).html_safe # rubocop:disable Rails/OutputSafety end - def highlight_tokens_in_html(html, tokens) + def highlight_tokens_in_html(html, tokens) # rubocop:disable Metrics/AbcSize doc = Nokogiri::HTML::DocumentFragment.parse(html) tokens.each do |token| @@ -84,7 +89,7 @@ module SearchHelper t = (tokens.index(node.content.downcase) || 0) % 4 highlighted_text = escaped_text.gsub(/(#{escaped_token})/i) do - %{#{$1}} + content_tag(:span, $1, class: "search-highlight token-#{t}") end node.replace(Nokogiri::HTML::DocumentFragment.parse(highlighted_text)) diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index dd1fd314595..6343e0410ab 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -163,7 +163,7 @@ module SettingsHelper def setting_label(setting, options = {}) label = options[:label] - return "".html_safe if label == false + return "" if label == false styled_label_tag( "settings_#{setting}", options[:not_translated_label] || I18n.t(label || "setting_#{setting}"), @@ -192,18 +192,23 @@ module SettingsHelper def build_settings_matrix_head(settings, options = {}) content_tag(:tr, class: "form--matrix-header-row") do content_tag(:th, I18n.t(options[:label_choices] || :label_choices), - class: "form--matrix-header-cell") + - settings.map do |setting| - content_tag(:th, class: "form--matrix-header-cell") do - hidden_field_tag("settings[#{setting}][]", "") + - I18n.t("setting_#{setting}") - end - end.join.html_safe + class: "form--matrix-header-cell") + build_settings_matrix_head_values(settings) end end + def build_settings_matrix_head_values(settings) + values = settings.map do |setting| + content_tag(:th, class: "form--matrix-header-cell") do + hidden_field_tag("settings[#{setting}][]", "") + + I18n.t("setting_#{setting}") + end + end + + safe_join(values) + end + def build_settings_matrix_body(settings, choices) - choices.map do |choice| + body = choices.map do |choice| value = choice[:value] caption = choice[:caption] || value.to_s exceptions = Array(choice[:except]).compact @@ -211,11 +216,13 @@ module SettingsHelper content_tag(:td, caption, class: "form--matrix-cell") + settings_matrix_tds(settings, exceptions, value) end - end.join.html_safe # rubocop:disable Rails/OutputSafety + end + + safe_join(body) end def settings_matrix_tds(settings, exceptions, value) - settings.map do |setting| + tds = settings.map do |setting| content_tag(:td, class: "form--matrix-checkbox-cell") do unless exceptions.include?(setting) styled_check_box_tag("settings[#{setting}][]", value, @@ -223,14 +230,16 @@ module SettingsHelper disabled_setting_option(setting).merge(id: "#{setting}_#{value}")) end end - end.join.html_safe # rubocop:disable Rails/OutputSafety + end + + safe_join(tds) end - def setting_multiselect_choice(setting, choice, options) + def setting_multiselect_choice(setting, choice, options) # rubocop:disable Metrics/AbcSize text, value, choice_options = (choice.is_a?(Array) ? choice : [choice, choice]) choice_options = disabled_setting_option(setting) - .merge(choice_options || {}) - .merge(options.except(:id)) + .merge(choice_options || {}) + .merge(options.except(:id)) choice_options[:id] = "#{setting}_#{value}" content_tag(:label, class: "form--label-with-check-box") do @@ -255,7 +264,7 @@ module SettingsHelper if writable_setting?(setting) yield else - "".html_safe + ActiveSupport::SafeBuffer.new end end end diff --git a/app/helpers/text_formatting_helper.rb b/app/helpers/text_formatting_helper.rb index 52fe33bb1bf..e9967abd146 100644 --- a/app/helpers/text_formatting_helper.rb +++ b/app/helpers/text_formatting_helper.rb @@ -80,7 +80,6 @@ module TextFormattingHelper end def truncate_formatted_text(text, length: 120, replace_newlines: true) - # rubocop:disable Rails/OutputSafety stripped_text = strip_tags(format_text(text.to_s)) stripped_text = if length @@ -91,13 +90,13 @@ module TextFormattingHelper .strip if replace_newlines - stripped_text - .gsub(/[\r\n]+/, "
    ") + stripped_text.gsub!(/[\r\n]+/, "
    ") else stripped_text end - .html_safe - # rubocop:enable Rails/OutputSafety + + # Sanitized through strip_tags + stripped_text.html_safe # rubocop:disable Rails/OutputSafety end def truncate_multiline(string, length) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index e485e80ffe7..779cda7ff61 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -89,11 +89,12 @@ module UsersHelper # Create buttons to lock/unlock a user and reset failed logins def build_change_user_status_action(user) - result = "".html_safe - iterate_user_statusses(user) do |title, name| - result << ((yield title, name) + " ") # rubocop:disable Style/StringConcatenation + capture do + iterate_user_statusses(user) do |title, name| + concat yield(title, name) + concat " " + end end - result end def iterate_user_statusses(user) diff --git a/app/helpers/versions_helper.rb b/app/helpers/versions_helper.rb index bbe9750bc07..359a3ab75d9 100644 --- a/app/helpers/versions_helper.rb +++ b/app/helpers/versions_helper.rb @@ -45,7 +45,7 @@ module VersionsHelper html_options = html_options.merge(id: link_to_version_id(version)) - link_name = options[:before_text].to_s.html_safe + format_version_name(version, options[:project] || @project) + link_name = format_version_name(version, options[:project] || @project) # rubocop:disable Rails/HelperInstanceVariable link_to_if version.visible?, link_name, { controller: "/versions", action: "show", id: version }, @@ -57,7 +57,7 @@ module VersionsHelper %i[start_date due_date] .filter { |attr| version.send(attr) } .map { |attr| "#{Version.human_attribute_name(attr)} #{format_date(version.send(attr))}" } - safe_join(formatted_dates, "
    ".html_safe) + safe_join(formatted_dates, tag(:br)) end def link_to_version_id(version) diff --git a/app/helpers/work_packages_helper.rb b/app/helpers/work_packages_helper.rb index 7a886b759d0..71aee969546 100644 --- a/app/helpers/work_packages_helper.rb +++ b/app/helpers/work_packages_helper.rb @@ -39,7 +39,7 @@ module WorkPackagesHelper # link_to_work_package(package, link_subject: true) # => Defect #6: This is the subject (everything within the link) # link_to_work_package(package, display_project: true) # => Foo - Defect #6: This is the subject def link_to_work_package(work_package, display_project: false, link_subject: false) # rubocop:disable Metrics/AbcSize - output = "".html_safe + output = ActiveSupport::SafeBuffer.new output << "#{work_package.project} - " if display_project && work_package.project_id link = link_to(work_package_path(work_package), @@ -101,22 +101,6 @@ module WorkPackagesHelper end end - def work_package_associations_to_address(associated) - ret = "".html_safe - - ret += content_tag(:p, I18n.t(:text_destroy_with_associated), class: "bold") - - ret += content_tag(:ul) do - associated.inject("".html_safe) do |list, associated_class| - list += content_tag(:li, associated_class.model_name.human, class: "decorated") - - list - end - end - - ret - end - def back_url_is_wp_show? route = Rails.application.routes.recognize_path(params[:back_url] || request.env["HTTP_REFERER"]) route[:controller] == "work_packages" && route[:action] == "index" && route[:state]&.match?(/^\d+/) diff --git a/app/models/activities/project_activity_provider.rb b/app/models/activities/project_activity_provider.rb index a9d2c98c53f..10675691426 100644 --- a/app/models/activities/project_activity_provider.rb +++ b/app/models/activities/project_activity_provider.rb @@ -51,7 +51,7 @@ class Activities::ProjectActivityProvider < Activities::BaseActivityProvider end def event_title(event) - I18n.t("events.title.project", name: event["project_name"]) + I18n.t("events.title.project_html", name: event["project_name"]) end def event_path(event) diff --git a/app/views/account/exit.html.erb b/app/views/account/exit.html.erb index 8b670699ccf..0232815f3c6 100644 --- a/app/views/account/exit.html.erb +++ b/app/views/account/exit.html.erb @@ -33,14 +33,10 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_login) %> <%= call_hook :view_account_login_top %> -<% - signin_link = link_to I18n.t("label_here"), signin_path - instruction_text = I18n.t "instructions_#{instructions}", signin: signin_link -%> -

    <%= I18n.t(:label_login) %>


    -

    <%= instruction_text.html_safe %>

    +

    <%= link_translate "instructions_#{instructions}", + links: { signin_url: signin_path } %>

    <%= call_hook :view_account_login_bottom %> diff --git a/app/views/activities/index.html.erb b/app/views/activities/index.html.erb index b203eae5ebc..ca1e750ed55 100644 --- a/app/views/activities/index.html.erb +++ b/app/views/activities/index.html.erb @@ -31,7 +31,13 @@ See COPYRIGHT and LICENSE files for more details. <%= render Primer::OpenProject::PageHeader.new do |header| - header.with_title { (@author.nil? ? t(:label_activity) : t(:label_user_activity, value: link_to_user(@author))).html_safe } + header.with_title do + if @author.nil? + t(:label_activity) + else + t(:label_user_activity_html, value: link_to_user(@author)) + end + end header.with_description { t(:label_date_from_to, start: format_date(@date_to - @days), end: format_date(@date_to - 1)) } header.with_breadcrumbs(@project ? [{ href: project_overview_path(@project.id), text: @project.name }, t(:label_activity)] : nil) end diff --git a/app/views/admin/backups/reset_token.html.erb b/app/views/admin/backups/reset_token.html.erb index 1a4a4b2f24e..408dd7e4813 100644 --- a/app/views/admin/backups/reset_token.html.erb +++ b/app/views/admin/backups/reset_token.html.erb @@ -62,10 +62,12 @@ See COPYRIGHT and LICENSE files for more details.

    <%= t( - "backup.reset_token.verification", - word: "#{t("backup.reset_token.verification_word_#{action}")}", + "backup.reset_token.verification_html", + word: content_tag(:em, + t("backup.reset_token.verification_word_#{action}"), + class: "danger-zone--expected-value"), action: t("backup.reset_token.verification_word_#{action}") - ).html_safe %> + ) %>

    diff --git a/app/views/admin/info.html.erb b/app/views/admin/info.html.erb index 21d2e9821d2..e59a882016b 100644 --- a/app/views/admin/info.html.erb +++ b/app/views/admin/info.html.erb @@ -58,14 +58,12 @@ See COPYRIGHT and LICENSE files for more details. scheme: :warning, icon: :alert )) do - <<~STR.html_safe - Starting with OpenProject 16.0, PostgreSQL 16 is required to use OpenProject. - Your installation will remain functional with your current database, but anticipate incompatability - in future releases. -
    - We have prepared #{static_link_to(:postgres_17_upgrade, label: 'upgrade guides for all installation methods')}. - You can perform the upgrade ahead of the next release at any time by following the guides. - STR + link_translate( + :"admin.info.database_deprecation_html", + links: { + upgrade_guide: %i[postgres_17_upgrade] + } + ) end component.with_attribute(key: "Deprecation warning", value:) end @@ -103,7 +101,7 @@ end %> component.with_attribute( key: t(:label_storage_for), - value: safe_join(entries[:labels], "
    ".html_safe) + value: safe_join(entries[:labels], tag(:br)) ) component.with_attribute( diff --git a/app/views/admin/settings/authentication_settings/_passwords.html.erb b/app/views/admin/settings/authentication_settings/_passwords.html.erb index d577d74850e..d6139ab465c 100644 --- a/app/views/admin/settings/authentication_settings/_passwords.html.erb +++ b/app/views/admin/settings/authentication_settings/_passwords.html.erb @@ -29,9 +29,9 @@ See COPYRIGHT and LICENSE files for more details. <%= settings_primer_form_with(scope: :settings, - url: admin_settings_authentication_path(tab: params[:tab]), - data: { turbo_method: :patch }, - method: :patch) do |f| + url: admin_settings_authentication_path(tab: params[:tab]), + data: { turbo_method: :patch }, + method: :patch) do |f| render_inline_settings_form(f) do |form| disabled = OpenProject::Configuration.disable_password_login? @@ -42,10 +42,8 @@ See COPYRIGHT and LICENSE files for more details. icon: :info, my: 2 ) do - I18n.t( - :note_password_login_disabled, - configuration: static_link_to(:disable_password_login, label: I18n.t('label_configuration')) - ).html_safe + link_translate(:note_password_login_disabled, + links: { configuration_url: %i[disable_password_login] }) end end end @@ -64,7 +62,7 @@ See COPYRIGHT and LICENSE files for more details. group.check_box(value:, label: I18n.t("label_password_rule_#{value}"), checked: OpenProject::Passwords::Evaluator.active_rule?(value), - ) + ) end end diff --git a/app/views/admin/settings/authentication_settings/_sso.html.erb b/app/views/admin/settings/authentication_settings/_sso.html.erb index 8bec979a71b..00010a692ff 100644 --- a/app/views/admin/settings/authentication_settings/_sso.html.erb +++ b/app/views/admin/settings/authentication_settings/_sso.html.erb @@ -37,10 +37,10 @@ See COPYRIGHT and LICENSE files for more details. name: :omniauth_direct_login_provider, input_width: :medium, label: I18n.t(:setting_omniauth_direct_login_provider), - caption: I18n.t( + caption: t( "settings.authentication.omniauth_direct_login_hint_html", internal_path: internal_signin_url - ).html_safe, + ), include_blank: I18n.t(:label_none_parentheses) ) do |select| AuthProvider diff --git a/app/views/members/_member_form.html.erb b/app/views/members/_member_form.html.erb index 0d9a4b267c8..5db509c6a23 100644 --- a/app/views/members/_member_form.html.erb +++ b/app/views/members/_member_form.html.erb @@ -102,7 +102,11 @@ See COPYRIGHT and LICENSE files for more details.
    -

    <%= I18n.t("warning_user_limit_reached#{'_admin' if current_user.admin?}", upgrade_url: OpenProject::Enterprise.upgrade_url).html_safe %>

    +

    <%= + link_translate( + "warning_user_limit_reached#{'_admin' if current_user.admin?}", + links: { upgrade_url: OpenProject::Enterprise.upgrade_url } + ) %>

    <% end %> diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb index 11306d8f317..701412bedb5 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -29,7 +29,11 @@ See COPYRIGHT and LICENSE files for more details. <%= render Primer::OpenProject::PageHeader.new do |header| - header.with_title { "#{avatar(@news.author)} #{h @news.title}".html_safe } + header.with_title do + concat avatar(@news.author) + concat @news.title + end + header.with_breadcrumbs( [ { href: project_overview_path(@project.id), text: @project.name }, diff --git a/app/views/placeholder_users/deletion_info.html.erb b/app/views/placeholder_users/deletion_info.html.erb index 2ccce86ce66..c8efb773b70 100644 --- a/app/views/placeholder_users/deletion_info.html.erb +++ b/app/views/placeholder_users/deletion_info.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% name = @placeholder_user.name %> -<% html_title(t(:label_administration), t("placeholder_users.deletion_info.heading", name: name).to_s) -%> +<% html_title(t(:label_administration), t("placeholder_users.deletion_info.heading_html", name:).to_s) -%> <%= labelled_tabular_form_for( :placeholder_user, @@ -40,11 +40,7 @@ See COPYRIGHT and LICENSE files for more details.

    - <%= t( - "placeholder_users.deletion_info.heading", - name: content_tag(:em, name) - ) - .html_safe %> + <%= t("placeholder_users.deletion_info.heading_html", name: content_tag(:em, name)) %>

    @@ -56,9 +52,9 @@ See COPYRIGHT and LICENSE files for more details.

    <%= t( - "placeholder_users.deletion_info.confirmation", + "placeholder_users.deletion_info.confirmation_html", name: content_tag(:em, name, class: "danger-zone--expected-value") - ).html_safe %> + ) %>

    diff --git a/app/views/project_mailer/copy_project_failed.html.erb b/app/views/project_mailer/copy_project_failed.html.erb index a47494ba1fe..ad978bfa6c7 100644 --- a/app/views/project_mailer/copy_project_failed.html.erb +++ b/app/views/project_mailer/copy_project_failed.html.erb @@ -31,4 +31,4 @@ See COPYRIGHT and LICENSE files for more details.

    <%= t("copy_project.text.failed", source_project_name: @source_project.name, target_project_name: @target_project_name) %>

    -<%= @errors.join("
    ".html_safe) %> +<%= safe_join @errors, tag(:br) %> diff --git a/app/views/repositories/_properties.html.erb b/app/views/repositories/_properties.html.erb new file mode 100644 index 00000000000..d7e3afb59bd --- /dev/null +++ b/app/views/repositories/_properties.html.erb @@ -0,0 +1,39 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% if @properties.present? %> +
      + <% @properties.keys.sort.each do |property| %> +
    • + <%= property %>: + <%= @properties[property] %> +
    • + <% end %> +
    +<% end %> diff --git a/app/views/repositories/changes.html.erb b/app/views/repositories/changes.html.erb index 79c5ac7f0db..255628fcbfb 100644 --- a/app/views/repositories/changes.html.erb +++ b/app/views/repositories/changes.html.erb @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details.

    <%= render partial: "link_to_functions" %>

    -<%= render_properties(@properties) %> +<%= render partial: "properties" %> <%= unless @changesets.empty? render( diff --git a/app/views/repositories/destroy_info.html.erb b/app/views/repositories/destroy_info.html.erb index 7695ef97592..ea64225adb2 100644 --- a/app/views/repositories/destroy_info.html.erb +++ b/app/views/repositories/destroy_info.html.erb @@ -42,12 +42,15 @@ See COPYRIGHT and LICENSE files for more details. <%= styled_form_tag(project_repository_path(@project), method: :delete, class: "danger-zone") do %>

    - <%= t("repositories.destroy.title", repository_type: "#{h(@repository.repository_type)} - #{t(:project_module_repository)}").html_safe %> + <%= t("repositories.destroy.title_html", repository_type: content_tag(:em, "#{@repository.repository_type} - #{t(:project_module_repository)}")) %>

    - <%= t("repositories.destroy.subtitle", repository_type: "#{h(@repository.repository_type)} - #{t(:project_module_repository)}", project_name: h(@project.identifier)).html_safe %>
    + <%= t("repositories.destroy.subtitle", + repository_type: "#{@repository.repository_type} - #{t(:project_module_repository)}", + project_name: @project.identifier) %> +
    <%= t("repositories.destroy.confirmation") %> -
    +
    <%= t("repositories.destroy.managed_path_note", path: @repository.root_url) %>

    @@ -55,7 +58,8 @@ See COPYRIGHT and LICENSE files for more details. <%= t("repositories.destroy.info") %>

    - <%= t("repositories.destroy.repository_verification", identifier: "#{h(@project.identifier)}").html_safe %> + <%= t("repositories.destroy.repository_verification_html", + identifier: content_tag(:em, @project.identifier, class: "danger-zone--expected-value")) %>

    @@ -66,13 +70,13 @@ See COPYRIGHT and LICENSE files for more details. title: t(:button_delete), disabled: true, class: "-primary" do %> - <%= op_icon("button--icon icon-delete") %> - <%= t(:button_delete) %> + <%= op_icon("button--icon icon-delete") %> + <%= t(:button_delete) %> <% end %> <%= link_to project_settings_repository_path(@project), title: t(:button_cancel), class: "button -with-icon icon-cancel" do %> - <%= t(:button_cancel) %> + <%= t(:button_cancel) %> <% end %>
    @@ -80,14 +84,20 @@ See COPYRIGHT and LICENSE files for more details. <% else %>
    -

    <%= t("repositories.destroy.title_not_managed", repository_type: "#{h(@repository.repository_type)} - #{t(:project_module_repository)}").html_safe %>

    +

    + + <%= t("repositories.destroy.title_not_managed", + repository_type: content_tag(:em, "#{@repository.repository_type} - #{t(:project_module_repository)}")) %> + +
    +

    <%= t( - "repositories.destroy.subtitle_not_managed", - repository_type: "#{h(@repository.repository_type)} - #{t(:project_module_repository)}", - project_name: h(@project.identifier), - url: h(@repository.url) - ).html_safe %>
    + "repositories.destroy.subtitle_not_managed_html", + repository_type: "#{@repository.repository_type} - #{t(:project_module_repository)}", + project_name: @project.identifier, + url: @repository.url + ) %>

    <%= t("repositories.destroy.info_not_managed") %> @@ -106,7 +116,7 @@ See COPYRIGHT and LICENSE files for more details. <%= link_to project_settings_repository_path(@project), title: t(:button_cancel), class: "button -with-icon icon-cancel" do %> - <%= t(:button_cancel) %> + <%= t(:button_cancel) %> <% end %>

    diff --git a/app/views/repositories/diff.html.erb b/app/views/repositories/diff.html.erb index e598558ae2a..587cc82e30b 100644 --- a/app/views/repositories/diff.html.erb +++ b/app/views/repositories/diff.html.erb @@ -65,14 +65,11 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: 'common/diff', locals: { diff: @diff, diff_type: @diff_type } %> <% end -%> <%= other_formats_links do |f| %> - <% unidiff_link = f.link_to 'Diff', url: permitted_params.repository_diff.to_h, caption: 'Unified diff' %> - <% if !@path.blank? %> - <% unidiff_link.gsub!('?', '&') %> - <% end %> - <% wrong_url = CGI::escapeHTML(CGI.escape(with_leading_slash(@path))).concat('.diff') %> - <% good_url = '.diff'.concat('?repo_path=', CGI.escape(without_leading_slash(@path)).gsub('&', '%26')) %> - <% unidiff_link.gsub!(wrong_url, good_url) %> - <%= unidiff_link.html_safe %> + <% + unidiff_params = permitted_params.repository_diff.to_h.merge(format: "diff") + unidiff_params[:repo_path] = without_leading_slash(@path) if @path.present? + %> + <%= f.link_to "Diff", url: unidiff_params, caption: "Unified diff" %> <% end %> <% html_title(h(with_leading_slash(@path)), 'Diff') -%> diff --git a/app/views/repositories/show.html.erb b/app/views/repositories/show.html.erb index 13b4865fa08..2a2a433311b 100644 --- a/app/views/repositories/show.html.erb +++ b/app/views/repositories/show.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "dir_list" %> <% end %> -<%= render_properties(@properties) %> +<%= render partial: "properties" %>
    <%# rev => nil prevents overwriting the rev parameter queried for in the form with the parameter from the request %> diff --git a/app/views/sharing_mailer/shared_work_package.html.erb b/app/views/sharing_mailer/shared_work_package.html.erb index f1a3da18d3a..2f15ffa749e 100644 --- a/app/views/sharing_mailer/shared_work_package.html.erb +++ b/app/views/sharing_mailer/shared_work_package.html.erb @@ -36,12 +36,12 @@ <% - formatted_actions = @allowed_work_package_actions.map do |action| - "#{action.downcase}" + allowed_actions = @allowed_work_package_actions.map do |action| + content_tag(:span, action.downcase, class: "-bold") end.to_sentence %> - <%= t("mail.sharing.work_packages.allowed_actions", allowed_actions: formatted_actions).html_safe %> + <%= t("mail.sharing.work_packages.allowed_actions_html", allowed_actions:) %> diff --git a/app/views/sharing_mailer/shared_work_package.text.erb b/app/views/sharing_mailer/shared_work_package.text.erb index 5fdaff428a9..4e48dd1f14a 100644 --- a/app/views/sharing_mailer/shared_work_package.text.erb +++ b/app/views/sharing_mailer/shared_work_package.text.erb @@ -23,7 +23,7 @@ <%= "=" * (("# " + @work_package.id.to_s + @work_package.subject).length + 4) %> <%= I18n.t("mail.work_packages.reason.shared") %>: -<%= t("mail.sharing.work_packages.allowed_actions", allowed_actions: @allowed_work_package_actions.to_sentence).html_safe %> +<%= t("mail.sharing.work_packages.allowed_actions_html", allowed_actions: @allowed_work_package_actions.to_sentence) %> <%= t("mail.sharing.work_packages.open_work_package") %> <%= @url %> diff --git a/app/views/user_mailer/activation_limit_reached.html.erb b/app/views/user_mailer/activation_limit_reached.html.erb index f26ca7e8c75..4066916bdfd 100644 --- a/app/views/user_mailer/activation_limit_reached.html.erb +++ b/app/views/user_mailer/activation_limit_reached.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details.

    <% mail_link = content_tag(:a, @email, href: "mailto:#{@email}") %> <% host_link = content_tag(:a, Setting.app_title, href: Rails.application.root_url) %> - <%= t("mail_user_activation_limit_reached.message", email: mail_link, host: host_link).html_safe %> + <%= t("mail_user_activation_limit_reached.message_html", email: mail_link, host: host_link) %>

    <%= t("mail_user_activation_limit_reached.steps.label") %> diff --git a/app/views/user_mailer/activation_limit_reached.text.erb b/app/views/user_mailer/activation_limit_reached.text.erb index 1c184667b63..39d8afaa668 100644 --- a/app/views/user_mailer/activation_limit_reached.text.erb +++ b/app/views/user_mailer/activation_limit_reached.text.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= t("mail_user_activation_limit_reached.message", email: @email, host: Rails.application.root_url) %> +<%= t("mail_user_activation_limit_reached.message_html", email: @email, host: Rails.application.root_url) %> <%= t("mail_user_activation_limit_reached.steps.label") %> - <%= t("mail_user_activation_limit_reached.steps.a").sub(link_regex, OpenProject::Enterprise.upgrade_url) %> diff --git a/app/views/users/_preferences.html.erb b/app/views/users/_preferences.html.erb index 0968a94229c..02be01dfd76 100644 --- a/app/views/users/_preferences.html.erb +++ b/app/views/users/_preferences.html.erb @@ -28,11 +28,11 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= fields_for :pref, @user.pref, builder: TabularFormBuilder, lang: current_language do |pref_fields| %> <%= render Settings::TimeZoneSettingComponent.new( - "time_zone", - form: pref_fields, - include_blank: false, - container_class: defined?(input_size) ? "-#{input_size}" : "-wide" - ) %> + "time_zone", + form: pref_fields, + include_blank: false, + container_class: defined?(input_size) ? "-#{input_size}" : "-wide" + ) %>
    <%= pref_fields.select :theme, theme_options_for_select, container_class: "-middle" %>
    @@ -44,10 +44,8 @@ See COPYRIGHT and LICENSE files for more details. <%= pref_fields.check_box :disable_keyboard_shortcuts, label: I18n.t("activerecord.attributes.user_preference.disable_keyboard_shortcuts") %> - <%= I18n.t( - "activerecord.attributes.user_preference.disable_keyboard_shortcuts_caption_html", - href: OpenProject::Static::Links.url_for(:shortcuts) - ).html_safe %> + <%= link_translate(:"user_preferences.disable_keyboard_shortcuts_caption", + links: { docs_url: %i[shortcuts] }) %>
    <% end %> diff --git a/app/views/work_packages/bulk/reassign.html.erb b/app/views/work_packages/bulk/reassign.html.erb index afd297f59f2..62dd5e752bd 100644 --- a/app/views/work_packages/bulk/reassign.html.erb +++ b/app/views/work_packages/bulk/reassign.html.erb @@ -48,8 +48,12 @@ See COPYRIGHT and LICENSE files for more details. <%= t("work_package.destroy.title") %>

    - <%= work_package_associations_to_address(associated) %> - +

    <%= t(:text_destroy_with_associated) %>

    +
      + <% associated.each do |associated_class| %> +
    • <%= associated_class.model_name.human %>
    • + <% end %> +

    <%= t(:text_destroy_what_to_do) %>

    @@ -108,9 +112,9 @@ See COPYRIGHT and LICENSE files for more details. <% end %>
    <%= styled_button_tag title: t(:button_delete), class: "-primary" do - concat content_tag :i, "", class: "button--icon icon-delete" - concat content_tag :span, t(:button_delete), class: "button--text" - end %> + concat content_tag :i, "", class: "button--icon icon-delete" + concat content_tag :span, t(:button_delete), class: "button--text" + end %> <%= link_to_function t(:button_cancel), "history.back()", title: t(:button_cancel), diff --git a/app/views/workflows/copy.html.erb b/app/views/workflows/copy.html.erb index f925c158e06..b07841c4eec 100644 --- a/app/views/workflows/copy.html.erb +++ b/app/views/workflows/copy.html.erb @@ -39,18 +39,19 @@ See COPYRIGHT and LICENSE files for more details. <%= select_tag( "source_type_id", - "".html_safe + - "".html_safe + - options_from_collection_for_select(@types, "id", "name", @source_type && @source_type.id), class: "form--select" + (blank_select_option + + content_tag(:option, "--- #{t(:label_copy_same_as_target)} ---") + + options_from_collection_for_select(@types, "id", "name", @source_type && @source_type.id)), + class: "form--select" ) %>
    <%= select_tag( "source_role_id", - "".html_safe + - "".html_safe + - options_from_collection_for_select(@roles, "id", "name", @source_role && @source_role.id), class: "form--select" + (blank_select_option + + content_tag(:option, "--- #{t(:label_copy_same_as_target)} ---") + + options_from_collection_for_select(@roles, "id", "name", @source_type && @source_type.id)), ) %>
    @@ -62,16 +63,14 @@ See COPYRIGHT and LICENSE files for more details.
    <%= select_tag "target_type_ids", - "".html_safe + - options_from_collection_for_select(@types, "id", "name", @target_types && @target_types.map(&:id)), + blank_select_option + options_from_collection_for_select(@types, "id", "name", @target_types && @target_types.map(&:id)), multiple: true, size: 20, class: "form--select" %>
    <%= select_tag "target_role_ids", - "".html_safe + - options_from_collection_for_select(@roles, "id", "name", @target_roles && @target_roles.map(&:id)), + blank_select_option + options_from_collection_for_select(@roles, "id", "name", @target_roles && @target_roles.map(&:id)), multiple: true, size: 20, class: "form--select" %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 53810ac3649..b0a4b9d921e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -436,6 +436,15 @@ en: default_transitions: "Default transitions" user_author: "User is author" user_assignee: "User is assignee" + info: + database_deprecation_html: > + Starting with OpenProject 16.0, PostgreSQL 16 is required to use OpenProject. + Your installation will remain functional with your current database, but anticipate incompatability + in future releases. +
    + We have prepared [upgrade guides for all installation methods](upgrade_guide). + You can perform the upgrade ahead of the next release at any time by following the guides. + authentication: login_and_registration: "Login and registration" @@ -1101,9 +1110,9 @@ en: groups: member_in_these_groups: "This user is currently a member of the following groups:" no_results_title_text: This user is currently not a member in any group. - summary_with_more: Member of %{names} and %{count_link}. + summary_with_more_html: Member of %{names} and %{count_link}. more: "%{count} more" - summary: Member of %{names}. + summary_html: Member of %{names}. memberships: no_results_title_text: This user is currently not a member of a project. open_profile: "Open profile" @@ -1219,6 +1228,9 @@ en: work_days_count: one: "1 working day" other: "%{count} working days" + user_preferences: + disable_keyboard_shortcuts_caption: > + You can choose to disable default [keyboard shortcuts](docs_url) if you use a screen reader or want to avoid accidentally triggering an action with a shortcut. page: text: "Text" placeholder_users: @@ -1227,7 +1239,7 @@ en: You do not have the right to manage members for all projects that the placeholder user is a member of. delete_tooltip: "Delete placeholder user" deletion_info: - heading: "Delete placeholder user %{name}" + heading_html: "Delete placeholder user %{name}" data_consequences: > All occurrences of the placeholder user (e.g., as assignee, responsible or other user values) will be reassigned to an account called "Deleted user". @@ -1236,7 +1248,7 @@ en: it will not be possible to distinguish the data the user created from the data of another deleted account. irreversible: "This action is irreversible" - confirmation: "Enter the placeholder user name %{name} to confirm the deletion." + confirmation_html: "Enter the placeholder user name %{name} to confirm the deletion." priorities: edit: priority_color_text: | @@ -1272,7 +1284,7 @@ en: Check this option to exclude work packages with this status from totals of Work, Remaining work, and % Complete in a hierarchy. status_percent_complete_text: |- - In status-based progress calculation mode, the % Complete of a work + In [status-based progress calculation mode](setting_url), the % Complete of a work package is automatically set to this value when this status is selected. Ignored in work-based mode. status_readonly_html: | @@ -1560,7 +1572,7 @@ en: account for you or change the self registration limit for this provider. login_with_auth_provider: "or sign in with your existing account" signup_with_auth_provider: "or sign up using" - auth_source_login: Please login as %{login} to activate your account. + auth_source_login_html: Please login as %{login} to activate your account. omniauth_login: Please login to activate your account. actionview_instancetag_blank_option: "Please select" @@ -1853,8 +1865,6 @@ en: button_update_user_information: "Update profile" comments_sorting: "Display work package activity sorted by" disable_keyboard_shortcuts: "Disable keyboard shortcuts" - disable_keyboard_shortcuts_caption_html: |- - You can choose to disable default keyboard shortcuts if you use a screen reader or want to avoid accidentally triggering an action with a shortcut. dismissed_enterprise_banners: "Hidden enterprise banners" impaired: "Accessibility mode" auto_hide_popups: "Automatically hide success banners" @@ -2618,7 +2628,7 @@ en: 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. - verification: > + verification_html: > Enter %{word} to confirm you want to %{action} the backup token. verification_word_reset: reset verification_word_create: create @@ -3077,7 +3087,7 @@ en: title: one: "One day left of %{trial_plan} trial token" other: "%{count} days left of %{trial_plan} trial token" - description: "You have access to all %{trial_plan} features." + description_html: "You have access to all %{trial_plan} features." trial: not_found: "You have requested a trial token, but that request is no longer available. Please try again." wait_for_confirmation: "We sent you an email to confirm your address in order to retrieve a trial token." @@ -3096,11 +3106,8 @@ en: confirmation_subline: > Please, check your inbox and follow the steps to start your 14-day free trial. domain_caption: The token will be valid for your currently configured host name. - receive_newsletter_html: > - I want to receive the OpenProject newsletter. - consent_html: > - I agree with the terms of service - and the privacy policy. + receive_newsletter: "I want to receive the OpenProject [newsletter](newsletter_url)." + consent: "I agree with the [terms of service](tos_url) and the [privacy policy](privacy_url)." email_calendar_updates: state: @@ -3195,8 +3202,8 @@ en: work_package_edit: "Work Package edited" work_package_note: "Work Package note added" title: - project: "Project: %{name}" - subproject: "Subproject: %{name}" + project_html: "Project: %{name}" + subproject_html: "Subproject: %{name}" export: dialog: @@ -3466,9 +3473,9 @@ en: configuration_guide: "Configuration guide" get_in_touch: "You have questions? Get in touch with us." - instructions_after_registration: "You can sign in as soon as your account has been activated by clicking %{signin}." - instructions_after_logout: "You can sign in again by clicking %{signin}." - instructions_after_error: "You can try to sign in again by clicking %{signin}. If the error persists, ask your admin for help." + instructions_after_registration: "You can sign in as soon as your account has been activated by clicking [here](signin_url)." + instructions_after_logout: "You can sign in again by clicking [here](signin_url)." + instructions_after_error: "You can try to sign in again by clicking [here](signin_url). If the error persists, ask your admin for help." menus: admin: @@ -3664,6 +3671,8 @@ en: label_calendar_show: "Show Calendar" label_category: "Category" label_completed: Completed + label_committed_at_html: "%{committed_revision_link} at %{date}" + label_committed_link: "committed revision %{revision_identifier}" label_consent_settings: "User Consent" label_wiki_menu_item: Wiki menu item label_select_main_menu_item: Select new main menu item @@ -4189,7 +4198,7 @@ en: label_user: "User" label_user_and_permission: "Users and permissions" label_user_named: "User %{name}" - label_user_activity: "%{value}'s activity" + label_user_activity_html: "%{value}'s activity" label_user_anonymous: "Anonymous" label_user_menu: "User menu" label_user_new: "New user" @@ -4373,7 +4382,7 @@ en: note: "Note: “%{note}”" sharing: work_packages: - allowed_actions: "You may %{allowed_actions} this work package. This can change depending on your project role and permissions." + allowed_actions_html: "You may %{allowed_actions} this work package. This can change depending on your project role and permissions." create_account: "To access this work package, you will need to create and activate an account on %{instance}." open_work_package: "Open work package" subject: "Work package #%{id} was shared with you" @@ -4476,7 +4485,7 @@ en: mail_user_activation_limit_reached: subject: User activation limit reached - message: | + message_html: | A new user (%{email}) tried to create an account on an OpenProject environment that you manage (%{host}). The user cannot activate their account since the user limit has been reached. steps: @@ -4832,10 +4841,10 @@ en: info: "Deleting the repository is an irreversible action." info_not_managed: "Note: This will NOT delete the contents of this repository, as it is not managed by OpenProject." managed_path_note: "The following directory will be erased: %{path}" - repository_verification: "Enter the project's identifier %{identifier} to verify the deletion of its repository." + repository_verification_html: "Enter the project's identifier %{identifier} to verify the deletion of its repository." subtitle: "Do you really want to delete the %{repository_type} of the project %{project_name}?" - subtitle_not_managed: "Do you really want to remove the linked %{repository_type} %{url} from the project %{project_name}?" - title: "Delete the %{repository_type}" + subtitle_not_managed_html: "Do you really want to remove the linked %{repository_type} %{url} from the project %{project_name}?" + title_html: "Delete the %{repository_type}" title_not_managed: "Remove the linked %{repository_type}?" errors: build_failed: "Unable to create the repository with the selected configuration. %{reason}" @@ -4946,7 +4955,7 @@ en: setting_apiv3_cors_origins_text_html: > If CORS is enabled, these are the origins that are allowed to access OpenProject API.
    - Please check the Documentation on the Origin header on how to specify the expected values. + Please check the [Documentation on the Origin header](docs_url) on how to specify the expected values. setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" setting_apiv3_write_readonly_attributes_instructions: > If enabled, the API will allow administrators to write static read-only attributes during creation, @@ -4956,7 +4965,7 @@ en: administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author. setting_apiv3_write_readonly_attributes_additional: > - For more information on attributes and supported resources, please see the %{api_documentation_link}. + For more information on attributes and supported resources, please see the [API documentation](api_documentation_link). setting_apiv3_max_page_size: "Maximum API page size" setting_apiv3_max_page_size_instructions: > Set the maximum page size the API will respond with. @@ -5366,7 +5375,10 @@ en: not_allowed_text: "You do not have the necessary permissions to view this page." activities: enable_internal_comments: "Enable internal comments" - helper_text: "Internal comments allow an internal team to communicate amongst themselves privately. These are only visible to selected roles that have the necessary permissions and will not be visible publicly. %{link}" + helper_text: > + Internal comments allow an internal team to communicate amongst themselves privately. + These are only visible to selected roles that have the necessary permissions and will not be visible publicly. + [Click here to learn more](docs_url) text_formatting: markdown: "Markdown" @@ -5647,21 +5659,23 @@ en: version_status_open: "open" note: Note - note_password_login_disabled: "Password login has been disabled by %{configuration}." + note_password_login_disabled: "Password login has been disabled through a [configuration setting](configuration_url)." warning: Warning warning_attachments_not_saved: "%{count} file(s) could not be saved." warning_imminent_user_limit: > You invited more users than are supported by your current plan. Invited users may not be able to join your OpenProject environment. - Please upgrade your plan or block existing + Please [upgrade your plan](upgrade_url) or block existing users in order to allow invited and registered users to join. warning_registration_token_expired: | The activation email has expired. We sent you a new one to %{email}. Please click the link inside of it to activate your account. 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 instance. + 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 instance. warning_user_limit_reached_admin: > - Adding additional users will exceed the current limit. Please upgrade your plan to be able to ensure external users are able to access this instance. + Adding additional users will exceed the current limit. + Please [upgrade your plan](upgrade_url) to be able to ensure external users are able to access this instance. warning_user_limit_reached_instructions: > You reached your user limit (%{current}/%{max} active users). Please contact sales@openproject.com to upgrade your Enterprise edition plan and add additional users. @@ -5730,7 +5744,7 @@ en: reminders: label_remind_at: "Date" note_placeholder: "Why are you setting this reminder?" - create_success_message: "Reminder set successfully. You will receive a notification for this work package %{reminder_time}." + create_success_message_html: "Reminder set successfully. You will receive a notification for this work package %{reminder_time}." success_update_message: "Reminder updated successfully." success_deletion_message: "Reminder deleted successfully." sharing: @@ -5760,9 +5774,11 @@ en: 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 %{entity}." 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 %{entity}. + 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 %{entity}. warning_user_limit_reached_admin: > - Adding additional users will exceed the current limit. Please upgrade your plan to be able to ensure external users are able to access this %{entity}. + Adding additional users will exceed the current limit. + Please [upgrade your plan](upgrade_url) to be able to ensure external users are able to access this %{entity}. warning_no_selected_user: "Please select users to share this %{entity} with" warning_locked_user: "The user %{user} is locked and cannot be shared with" user_details: diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 084a63c3edd..6e8a9765fc0 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -389,8 +389,6 @@ en: label_collapse_all: "Collapse all" label_collapse_text: "Collapse text" label_comment: "Comment" - label_committed_at: "%{committed_revision_link} at %{date}" - label_committed_link: "committed revision %{revision_identifier}" label_contains: "contains" label_created_on: "created on" label_edit_comment: "Edit this comment" diff --git a/config/static_links.yml b/config/static_links.yml index 7708b89c816..03c503d8288 100644 --- a/config/static_links.yml +++ b/config/static_links.yml @@ -201,6 +201,8 @@ sysadmin_docs: href: https://www.openproject.org/docs/system-admin-guide/authentication/scim/#step-3-choose-an-authentication-method scim_jwt_authetication_method: href: https://www.openproject.org/docs/system-admin-guide/authentication/scim/#c-jwt-from-identity-provider +terms_of_service: + href: https://www.openproject.org/terms-of-service/ text_formatting: href: https://www.openproject.org/docs/user-guide/wysiwyg/ label: :setting_text_formatting diff --git a/lib/custom_field_form_builder.rb b/lib/custom_field_form_builder.rb index 836361c992d..74b455567a1 100644 --- a/lib/custom_field_form_builder.rb +++ b/lib/custom_field_form_builder.rb @@ -142,15 +142,14 @@ class CustomFieldFormBuilder < TabularFormBuilder for: custom_field_field_id, class: classes, title: custom_field.name do - output = "".html_safe - output += custom_field.name + capture do + concat custom_field.name - # Render a help text icon - if options[:help_text] - output += content_tag("attribute-help-text", "", data: options[:help_text]) + # Render a help text icon + if options[:help_text] + concat content_tag("attribute-help-text", "", data: options[:help_text]) + end end - - output end end end diff --git a/lib/open_project/object_linking.rb b/lib/open_project/object_linking.rb index 584285675e6..58b4f628451 100644 --- a/lib/open_project/object_linking.rb +++ b/lib/open_project/object_linking.rb @@ -167,7 +167,11 @@ module OpenProject def project_link_name(project, show_icon) if show_icon && User.current.member_of?(project) - icon_wrapper("icon-context icon-star", I18n.t(:description_my_project).html_safe + " ".html_safe) + project.name + label = ActiveSupport::SafeBuffer.new + label << I18n.t(:description_my_project) + label << " ".html_safe + + icon_wrapper("icon-context icon-star", label) + project.name else project.name end diff --git a/lib/open_project/page_hierarchy_helper.rb b/lib/open_project/page_hierarchy_helper.rb index b102cadb240..0f1d3efa46c 100644 --- a/lib/open_project/page_hierarchy_helper.rb +++ b/lib/open_project/page_hierarchy_helper.rb @@ -39,7 +39,7 @@ module OpenProject concat render_page_hierarchy(pages, page.id, options) if is_parent end end - chunks.join.html_safe + safe_join(chunks) end end diff --git a/lib/open_project/patches/action_view_accessible_errors.rb b/lib/open_project/patches/action_view_accessible_errors.rb index cc9aa38cd11..8eef9d9f0be 100644 --- a/lib/open_project/patches/action_view_accessible_errors.rb +++ b/lib/open_project/patches/action_view_accessible_errors.rb @@ -44,7 +44,11 @@ module ActionView def wrap_with_error_span(html_tag, object, method) object_identifier = erroneous_object_identifier(object.object_id.to_s, method) - "#{html_tag}".html_safe + helpers = ActionController::Base.helpers + + helpers.content_tag(:span, id: object_identifier, class: "errorSpan") do + helpers.content_tag(:a, "", name: object_identifier) + html_tag + end end def erroneous_object_identifier(id, method) diff --git a/lib/redmine/menu_manager/menu_helper.rb b/lib/redmine/menu_manager/menu_helper.rb index 9b8384173ba..b369d892415 100644 --- a/lib/redmine/menu_manager/menu_helper.rb +++ b/lib/redmine/menu_manager/menu_helper.rb @@ -180,10 +180,10 @@ module Redmine::MenuManager::MenuHelper caption, url, selected = extract_node_details(item, project) shown_in_main_menu = menu_class == "op-menu" - link_text = "".html_safe + link_text = ActiveSupport::SafeBuffer.new if item.icon(project).present? - link_text += render(Primer::Beta::Octicon.new( + link_text << render(Primer::Beta::Octicon.new( icon: item.icon, mr: shown_in_main_menu ? 3 : 0, size: shown_in_main_menu ? :small : :medium @@ -192,7 +192,7 @@ module Redmine::MenuManager::MenuHelper badge_class = item.badge(project:).present? ? " #{menu_class}--item-title_has-badge" : "" - link_text += content_tag(:span, + link_text << content_tag(:span, class: "#{menu_class}--item-title#{badge_class}", lang: menu_item_locale(item)) do title_text = content_tag(:span, caption, class: "ellipsis") + badge_for(item) @@ -205,7 +205,7 @@ module Redmine::MenuManager::MenuHelper end if item.icon_after.present? - link_text += render(Primer::Beta::Octicon.new(icon: item.icon_after, classes: "trailing-icon")) + link_text << render(Primer::Beta::Octicon.new(icon: item.icon_after, classes: "trailing-icon")) end html_options = item.html_options(selected:) @@ -424,8 +424,6 @@ module Redmine::MenuManager::MenuHelper key = item.badge(project: @project) if key.present? content_tag("span", I18n.t(key), class: "main-item--badge") - else - "".html_safe end end diff --git a/lib/tabular_form_builder.rb b/lib/tabular_form_builder.rb index 4c55d02171c..9147756edd6 100644 --- a/lib/tabular_form_builder.rb +++ b/lib/tabular_form_builder.rb @@ -207,7 +207,7 @@ class TabularFormBuilder < ActionView::Helpers::FormBuilder if prefix ret.prepend content_tag(:span, - prefix.html_safe, + prefix, class: "form--field-affix", id: options[:prefix_id], "aria-hidden": true) @@ -215,7 +215,7 @@ class TabularFormBuilder < ActionView::Helpers::FormBuilder if suffix ret.concat content_tag(:span, - suffix.html_safe, + suffix, class: "form--field-affix", id: options[:suffix_id], "aria-hidden": true) diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index e6b739df5a0..597a25b87ed 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -290,7 +290,7 @@ module Redmine href:, target:, underline:, - data: { allow_external_link: true }, + data: { allow_external_link: true } ) component.with_trailing_visual_icon(icon: :"link-external") if external component.with_content(text) diff --git a/lookbook/docs/patterns/02-forms.md.erb b/lookbook/docs/patterns/02-forms.md.erb index 47f84f5bb0e..63c317a9891 100644 --- a/lookbook/docs/patterns/02-forms.md.erb +++ b/lookbook/docs/patterns/02-forms.md.erb @@ -439,8 +439,7 @@ class Admin::Settings::GeneralSettingsForm < ApplicationForm name: :per_page_options, label: I18n.t("setting_per_page_options"), value: Setting[:per_page_options], - caption: "#{I18n.t(:text_comma_separated)}
    " \ - "#{I18n.t(:text_notice_too_many_values_are_inperformant)}".html_safe) + caption: safe_join [I18n.t(:text_comma_separated), helpers.tag(:br), I18n.t(:text_notice_too_many_values_are_inperformant)], disabled: !Setting.per_page_options_writable? ) general_form.text_field( @@ -492,8 +491,9 @@ class Admin::Settings::GeneralSettingsForm < ApplicationForm settings_form do |general_form| general_form.text_field(name: :app_title) general_form.text_field(name: :per_page_options, - caption: "#{I18n.t(:text_comma_separated)}
    " \ - "#{I18n.t(:text_notice_too_many_values_are_inperformant)}".html_safe) + caption: safe_join [I18n.t(:text_comma_separated), + helpers.tag(:br), + I18n.t(:text_notice_too_many_values_are_inperformant)]) general_form.text_field(name: :activity_days_default, type: :number) general_form.text_field(name: :host_name, diff --git a/modules/auth_saml/app/views/saml/providers/confirm_destroy.html.erb b/modules/auth_saml/app/views/saml/providers/confirm_destroy.html.erb index a062532c586..57788a906b8 100644 --- a/modules/auth_saml/app/views/saml/providers/confirm_destroy.html.erb +++ b/modules/auth_saml/app/views/saml/providers/confirm_destroy.html.erb @@ -36,7 +36,7 @@ See COPYRIGHT and LICENSE files for more details. <%= t("saml.delete_title") %>

    - <%= t("provider.delete_warning.provider", name: content_tag(:strong, @provider.display_name)).html_safe %> + <%= t("provider.delete_warning.provider_html", name: content_tag(:strong, @provider.display_name)) %>