From f19a5855506fec1a94f9dbb2b37c9657d59e2d8c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 11 Nov 2025 19:17:00 +0000 Subject: [PATCH 01/18] Add Inline SVG dependency --- Gemfile | 2 ++ Gemfile.lock | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/Gemfile b/Gemfile index a9f38fbee1d6..94ffb42b4d13 100644 --- a/Gemfile +++ b/Gemfile @@ -232,6 +232,8 @@ gem "view_component", "~> 4.1.1" # Lookbook gem "lookbook", "2.3.13" +gem "inline_svg", "~> 1.10.0" + # Require factory_bot for usage with openproject plugins testing gem "factory_bot", "~> 6.5.6", require: false # require factory_bot_rails for convenience in core development diff --git a/Gemfile.lock b/Gemfile.lock index fc7e0b7296a9..aa9937fe711b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -718,6 +718,9 @@ GEM ostruct ice_cube (0.17.0) ice_nine (0.11.2) + inline_svg (1.10.0) + activesupport (>= 3.0) + nokogiri (>= 1.6) interception (0.5) io-console (0.8.1) irb (1.15.3) @@ -1610,6 +1613,7 @@ DEPENDENCIES i18n-tasks (~> 1.0.13) ice_cube (~> 0.17.0) ice_nine + inline_svg (~> 1.10.0) json_schemer (~> 2.4.0) json_spec (~> 1.1.4) ladle @@ -1946,6 +1950,7 @@ CHECKSUMS icalendar (2.12.1) sha256=ecff56c550aed551f29ad1faad0da54bf62362dfaf22a428bd7ad782938fe764 ice_cube (0.17.0) sha256=32deb45dda4b4acc53505c2f581f6d32b5afc04d29b9004769944a0df5a5fcbe ice_nine (0.11.2) sha256=5d506a7d2723d5592dc121b9928e4931742730131f22a1a37649df1c1e2e63db + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f interception (0.5) sha256=a53818d636752a8df90d8c1bb2f7b6e13a7b828543cb02b50fbde98b849d7907 io-console (0.8.1) sha256=1e15440a6b2f67b6ea496df7c474ed62c860ad11237f29b3bd187f054b925fcb irb (1.15.3) sha256=4349edff1efa7ff7bfd34cb9df74a133a588ba88c2718098b3b4468b81184aaa From 2c3a84df04f5f7906c60893df7eaa5b4a62ea074 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 14 Nov 2025 13:24:39 +0000 Subject: [PATCH 02/18] Implement advanced check, radio form controls --- .../src/global_styles/openproject/_forms.sass | 54 +++++++ .../forms/advanced_check_box.html.erb | 26 ++++ .../open_project/forms/advanced_check_box.rb | 40 +++++ .../forms/advanced_check_box_group.html.erb | 22 +++ .../forms/advanced_check_box_group.rb | 39 +++++ .../forms/advanced_radio_button.html.erb | 26 ++++ .../forms/advanced_radio_button.rb | 40 +++++ .../advanced_radio_button_group.html.erb | 22 +++ .../forms/advanced_radio_button_group.rb | 39 +++++ .../dsl/advanced_check_box_group_input.rb | 87 +++++++++++ .../forms/dsl/advanced_check_box_input.rb | 87 +++++++++++ .../dsl/advanced_radio_button_group_input.rb | 75 +++++++++ .../forms/dsl/advanced_radio_button_input.rb | 78 ++++++++++ .../open_project/forms/dsl/input_methods.rb | 8 + .../forms/advanced_check_box_group_spec.rb | 142 ++++++++++++++++++ .../forms/advanced_radio_button_group_spec.rb | 142 ++++++++++++++++++ .../forms/dsl/input_methods_spec.rb | 14 ++ 17 files changed, 941 insertions(+) create mode 100644 lib/primer/open_project/forms/advanced_check_box.html.erb create mode 100644 lib/primer/open_project/forms/advanced_check_box.rb create mode 100644 lib/primer/open_project/forms/advanced_check_box_group.html.erb create mode 100644 lib/primer/open_project/forms/advanced_check_box_group.rb create mode 100644 lib/primer/open_project/forms/advanced_radio_button.html.erb create mode 100644 lib/primer/open_project/forms/advanced_radio_button.rb create mode 100644 lib/primer/open_project/forms/advanced_radio_button_group.html.erb create mode 100644 lib/primer/open_project/forms/advanced_radio_button_group.rb create mode 100644 lib/primer/open_project/forms/dsl/advanced_check_box_group_input.rb create mode 100644 lib/primer/open_project/forms/dsl/advanced_check_box_input.rb create mode 100644 lib/primer/open_project/forms/dsl/advanced_radio_button_group_input.rb create mode 100644 lib/primer/open_project/forms/dsl/advanced_radio_button_input.rb create mode 100644 spec/lib/primer/open_project/forms/advanced_check_box_group_spec.rb create mode 100644 spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb diff --git a/frontend/src/global_styles/openproject/_forms.sass b/frontend/src/global_styles/openproject/_forms.sass index 38c0f64a858d..17180a8c977e 100644 --- a/frontend/src/global_styles/openproject/_forms.sass +++ b/frontend/src/global_styles/openproject/_forms.sass @@ -171,3 +171,57 @@ input[type="number"] &::-webkit-outer-spin-button -webkit-appearance: none margin: 0 + +.FormControl-advanced-check-group-wrap, +.FormControl-advanced-radio-group-wrap + & fieldset + padding: 0 + margin: 0 + border: 0 + +.FormControl-advanced-check-group-list, +.FormControl-advanced-radio-group-list + display: grid + grid-template-columns: repeat(2, 1fr) + gap: var(--base-size-12) var(--base-size-16) + + @media screen and (max-width: $breakpoint-sm) + grid-template-columns: 1fr + +.FormControl-advanced-checkbox-wrap, +.FormControl-advanced-radio-wrap + display: flex + background-color: var(--bgColor-inset) + border: var(--borderWidth-thin, 1px) solid var(--borderColor-default) + border-radius: var(--borderRadius-medium) + + &:has(input:hover) + background-color: var(--bgColor-accent-muted) + border-color: var(--borderColor-accent-muted) + + &:has(input:checked) + background-color: var(--bgColor-accent-muted) + border-color: var(--control-checked-borderColor-rest) + + & .FormControl-label + display: block + flex: 1 + cursor: pointer + +.FormControl-advanced-checkbox-content, +.FormControl-advanced-radio-content + display: grid + grid-template-columns: auto min-content + gap: var(--base-size-8) + padding: var(--base-size-12) + +.FormControl-advanced-checkbox-label-row, +.FormControl-advanced-radio-label-row + display: inline-grid + grid-template-columns: min-content auto + gap: var(--base-size-8) + color: var(--fgColor-accent) + +.FormControl-advanced-checkbox-icon, +.FormControl-advanced-radio-icon + fill: var(--fgColor-accent) diff --git a/lib/primer/open_project/forms/advanced_check_box.html.erb b/lib/primer/open_project/forms/advanced_check_box.html.erb new file mode 100644 index 000000000000..fe8d216ad43a --- /dev/null +++ b/lib/primer/open_project/forms/advanced_check_box.html.erb @@ -0,0 +1,26 @@ +<%= content_tag(:div, class: "FormControl-advanced-checkbox-wrap", hidden: @input.hidden?) do %> + <%= builder.label(@input.name, **@input.label_arguments) do %> +
+
+
+ <%= builder.check_box(@input.name, @input.input_arguments, checked_value, unchecked_value) %> + <%= @input.label %> +
+ <% if @input.form_control? %> +
+ <%= render(Caption.new(input: @input)) %> +
+ <% end %> +
+ <% if @input.icon.present? %> + <%= + inline_svg_tag( + @input.icon, + class: "FormControl-advanced-checkbox-icon", + aria_hidden: true + ) + %> + <% end %> +
+ <% end %> +<% end %> diff --git a/lib/primer/open_project/forms/advanced_check_box.rb b/lib/primer/open_project/forms/advanced_check_box.rb new file mode 100644 index 000000000000..6deea460effe --- /dev/null +++ b/lib/primer/open_project/forms/advanced_check_box.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 Primer + module OpenProject + module Forms + # :nodoc: + class AdvancedCheckBox < Primer::Forms::CheckBox + include InlineSvg::ActionView::Helpers + end + end + end +end diff --git a/lib/primer/open_project/forms/advanced_check_box_group.html.erb b/lib/primer/open_project/forms/advanced_check_box_group.html.erb new file mode 100644 index 000000000000..5319b650faf8 --- /dev/null +++ b/lib/primer/open_project/forms/advanced_check_box_group.html.erb @@ -0,0 +1,22 @@ +
+ <%= content_tag(:fieldset, **@input.input_arguments) do %> + <% if @input.label %> + <%= content_tag(:legend, **@input.label_arguments) do %> + <%= @input.label %> + <% end %> + <% end %> +
+ <%= render(Caption.new(input: @input)) %> +
+ <%= render(SpacingWrapper.new) do %> +
+ <% @input.check_boxes.each do |check_box| %> + <%= render(check_box.to_component) %> + <% end %> +
+ <% end %> + <% end %> +
+ <%= render(ValidationMessage.new(input: @input)) %> +
+
diff --git a/lib/primer/open_project/forms/advanced_check_box_group.rb b/lib/primer/open_project/forms/advanced_check_box_group.rb new file mode 100644 index 000000000000..367f5e2e348e --- /dev/null +++ b/lib/primer/open_project/forms/advanced_check_box_group.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 Primer + module OpenProject + module Forms + # :nodoc: + class AdvancedCheckBoxGroup < Primer::Forms::CheckBoxGroup + end + end + end +end diff --git a/lib/primer/open_project/forms/advanced_radio_button.html.erb b/lib/primer/open_project/forms/advanced_radio_button.html.erb new file mode 100644 index 000000000000..fd1bdb62a478 --- /dev/null +++ b/lib/primer/open_project/forms/advanced_radio_button.html.erb @@ -0,0 +1,26 @@ +<%= content_tag(:div, class: "FormControl-advanced-radio-wrap", hidden: @input.hidden?) do %> + <%= builder.label(@input.name, value: @input.value, **@input.label_arguments) do %> +
+
+
+ <%= builder.radio_button(@input.name, @input.value, **@input.input_arguments) %> + <%= @input.label %> +
+ <% if @input.form_control? %> +
+ <%= render(Caption.new(input: @input)) %> +
+ <% end %> +
+ <% if @input.icon.present? %> + <%= + inline_svg_tag( + @input.icon, + class: "FormControl-advanced-radio-icon", + aria_hidden: true + ) + %> + <% end %> +
+ <% end %> +<% end %> diff --git a/lib/primer/open_project/forms/advanced_radio_button.rb b/lib/primer/open_project/forms/advanced_radio_button.rb new file mode 100644 index 000000000000..c980970410cb --- /dev/null +++ b/lib/primer/open_project/forms/advanced_radio_button.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 Primer + module OpenProject + module Forms + # :nodoc: + class AdvancedRadioButton < Primer::Forms::RadioButton + include InlineSvg::ActionView::Helpers + end + end + end +end diff --git a/lib/primer/open_project/forms/advanced_radio_button_group.html.erb b/lib/primer/open_project/forms/advanced_radio_button_group.html.erb new file mode 100644 index 000000000000..b8b563b623f5 --- /dev/null +++ b/lib/primer/open_project/forms/advanced_radio_button_group.html.erb @@ -0,0 +1,22 @@ +
+ <%= content_tag(:fieldset, role: "radiogroup", **@input.input_arguments) do %> + <% if @input.label %> + <%= content_tag(:legend, **@input.label_arguments) do %> + <%= @input.label %> + <% end %> + <% end %> +
+ <%= render(Caption.new(input: @input)) %> +
+ <%= render(SpacingWrapper.new) do %> +
+ <% @input.radio_buttons.each do |radio_button| %> + <%= render(radio_button.to_component) %> + <% end %> +
+ <% end %> + <% end %> +
+ <%= render(ValidationMessage.new(input: @input)) %> +
+
diff --git a/lib/primer/open_project/forms/advanced_radio_button_group.rb b/lib/primer/open_project/forms/advanced_radio_button_group.rb new file mode 100644 index 000000000000..f4f76b8f067a --- /dev/null +++ b/lib/primer/open_project/forms/advanced_radio_button_group.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 Primer + module OpenProject + module Forms + # :nodoc: + class AdvancedRadioButtonGroup < Primer::Forms::RadioButtonGroup + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/advanced_check_box_group_input.rb b/lib/primer/open_project/forms/dsl/advanced_check_box_group_input.rb new file mode 100644 index 000000000000..3a9d6eeaafa7 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/advanced_check_box_group_input.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write 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 Primer + module OpenProject + module Forms + module Dsl + # :nodoc: + class AdvancedCheckBoxGroupInput < Primer::Forms::Dsl::Input + attr_reader :name, :label, :check_boxes + + def initialize(name: nil, label: nil, **system_arguments) + @name = name + @label = label + @check_boxes = [] + + super(**system_arguments) + + yield(self) if block_given? + end + + def to_component + AdvancedCheckBoxGroup.new(input: self) + end + + def type + :check_box_group + end + + def focusable? + true + end + + def autofocus! + @check_boxes.first&.autofocus! + end + + def check_box(**system_arguments, &) + args = { + name: @name, + builder: @builder, + form: @form, + scheme: scheme, + disabled: disabled?, + **system_arguments + } + + @check_boxes << AdvancedCheckBoxInput.new(**args, &) + end + + private + + def scheme + @name ? :array : :boolean + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/advanced_check_box_input.rb b/lib/primer/open_project/forms/dsl/advanced_check_box_input.rb new file mode 100644 index 000000000000..50e106b3a966 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/advanced_check_box_input.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write 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 Primer + module OpenProject + module Forms + module Dsl + # :nodoc: + class AdvancedCheckBoxInput < Primer::Forms::Dsl::Input + DEFAULT_SCHEME = :boolean + SCHEMES = [DEFAULT_SCHEME, :array].freeze + + attr_reader :name, :label, :value, :unchecked_value, :scheme, :icon + + def initialize(name:, label:, value: nil, unchecked_value: nil, scheme: DEFAULT_SCHEME, icon: nil, **system_arguments) + raise ArgumentError, "Check box scheme must be one of #{SCHEMES.join(', ')}" unless SCHEMES.include?(scheme) + + raise ArgumentError, "Check box needs an explicit value if scheme is array" if scheme == :array && value.nil? + + @name = name + @label = label + @value = value + @unchecked_value = unchecked_value + @scheme = scheme + @icon = icon + + super(**system_arguments) + + yield(self) if block_given? + end + + # check boxes cannot be invalid, as both checked and unchecked are valid states + # :nocov: + def valid? + true + end + # :nocov: + + def to_component + AdvancedCheckBox.new(input: self) + end + + def type + :check_box + end + + def supports_validation? + false + end + + def values_disambiguate_template_names? + # Check boxes submitted as an array all have the same name, so we return true here + # to ensure different caption templates can be attached to individual check box inputs. + @scheme == :array + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/advanced_radio_button_group_input.rb b/lib/primer/open_project/forms/dsl/advanced_radio_button_group_input.rb new file mode 100644 index 000000000000..66f6a5473267 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/advanced_radio_button_group_input.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. +#++ + +module Primer + module OpenProject + module Forms + module Dsl + # :nodoc: + class AdvancedRadioButtonGroupInput < Primer::Forms::Dsl::Input + attr_reader :name, :label, :radio_buttons + + def initialize(name:, label: nil, **system_arguments) + @name = name + @label = label + @radio_buttons = [] + + super(**system_arguments) + + yield(self) if block_given? + end + + def to_component + AdvancedRadioButtonGroup.new(input: self) + end + + def type + :radio_button_group + end + + def autofocus! + @radio_buttons.first&.autofocus! + end + + def focusable? + true + end + + def radio_button(**system_arguments, &) + @radio_buttons << AdvancedRadioButtonInput.new( + builder: @builder, form: @form, name: @name, disabled: disabled?, + **system_arguments, & + ) + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/advanced_radio_button_input.rb b/lib/primer/open_project/forms/dsl/advanced_radio_button_input.rb new file mode 100644 index 000000000000..766826e2149b --- /dev/null +++ b/lib/primer/open_project/forms/dsl/advanced_radio_button_input.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. +#++ + +module Primer + module OpenProject + module Forms + module Dsl + # :nodoc: + class AdvancedRadioButtonInput < Primer::Forms::Dsl::Input + attr_reader :name, :value, :label, :icon + + def initialize(name:, value:, label:, icon: nil, **system_arguments) + @name = name + @value = value + @label = label + @icon = icon + + super(**system_arguments) + + yield(self) if block_given? + end + + # radio buttons cannot be invalid, as both selected and unselected are valid states + # :nocov: + def valid? + true + end + # :nocov: + + def to_component + AdvancedRadioButton.new(input: self) + end + + # :nocov: + def type + :radio_button + end + # :nocov: + + def supports_validation? + false + end + + def values_disambiguate_template_names? + true + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index 7ca247fafb94..7c217c9e2c70 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -21,6 +21,14 @@ def check_box_group(**, &) super(**decorate_options(**), &) end + def advanced_radio_button_group(**, &) + add_input AdvancedRadioButtonGroupInput.new(builder:, form:, **decorate_options(**), &) + end + + def advanced_check_box_group(**, &) + add_input AdvancedCheckBoxGroupInput.new(builder:, form:, **decorate_options(**), &) + end + def autocompleter(**, &) add_input AutocompleterInput.new(builder:, form:, **decorate_options(**), &) end diff --git a/spec/lib/primer/open_project/forms/advanced_check_box_group_spec.rb b/spec/lib/primer/open_project/forms/advanced_check_box_group_spec.rb new file mode 100644 index 000000000000..316f458f0f51 --- /dev/null +++ b/spec/lib/primer/open_project/forms/advanced_check_box_group_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write 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 Primer::OpenProject::Forms::AdvancedCheckBoxGroup, type: :forms do + include ViewComponent::TestHelpers + + describe "rendering" do + let(:params) { {} } + let(:model) { build_stubbed(:comment) } + + def render_form + render_in_view_context(model, params) do |model, params| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |check_form| + check_form.advanced_check_box_group( + name: :ultimate_answer, + label: "Ultimate answer", + **params + ) do |group| + group.check_box( + value: "one", + label: "One", + caption: "Pick me", + icon: "icon_logo.svg" + ) + group.check_box( + value: "two", + label: "Two", + caption: "Don't pick me", + icon: "icon_logo.svg" + ) + group.check_box( + value: "three", + label: "Three", + icon: nil + ) + end + end + end + end + end + + subject(:rendered_form) do + render_form + page + end + + it "renders the fieldset" do + expect(rendered_form).to have_selector :fieldset, "Ultimate answer" + end + + it "renders the checkboxes", :aggregate_failures do + expect(rendered_form).to have_field "One", type: :checkbox, fieldset: "Ultimate answer" + expect(rendered_form).to have_field "Two", type: :checkbox, fieldset: "Ultimate answer" + expect(rendered_form).to have_field "Three", type: :checkbox, fieldset: "Ultimate answer" + end + + it "renders icons" do + expect(rendered_form).to have_element :svg, count: 2, aria: { hidden: true } + end + + it "renders captions", :aggregate_failures do + expect(rendered_form).to have_css ".FormControl-caption", count: 2 + expect(rendered_form).to have_css ".FormControl-caption", text: "Pick me" + expect(rendered_form).to have_css ".FormControl-caption", text: "Don't pick me" + end + end + + describe "standard checkbox group compatibility" do + specify "hidden checkbox group", :aggregate_failures do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |check_form| + check_form.advanced_check_box_group(label: "Foobar", hidden: true) do |check_group| + check_group.check_box(name: :foo, label: "Foo") + end + end + end + end + + expect(page).to have_selector :fieldset, visible: :hidden + expect(page).to have_css ".FormControl-advanced-checkbox-wrap", visible: :hidden + end + + specify "disabled checkbox group disables constituent checkboxes" do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |check_form| + check_form.advanced_check_box_group(label: "Foobar", disabled: true) do |check_group| + check_group.check_box(name: :foo, label: "Foo") + end + end + end + end + + expect(page).to have_css ".FormControl-advanced-checkbox-wrap input[disabled]" + end + + specify "checkbox can be individually disabled in group" do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |check_form| + check_form.advanced_check_box_group(label: "Foobar") do |check_group| + check_group.check_box(name: :foo, label: "Foo", disabled: true) + end + end + end + end + + expect(page).to have_css ".FormControl-advanced-checkbox-wrap input[disabled]" + end + end +end diff --git a/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb b/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb new file mode 100644 index 000000000000..19b5ece9cca6 --- /dev/null +++ b/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write 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 Primer::OpenProject::Forms::AdvancedRadioButtonGroup, type: :forms do + include ViewComponent::TestHelpers + + describe "rendering" do + let(:params) { {} } + let(:model) { build_stubbed(:comment) } + + def render_form + render_in_view_context(model, params) do |model, params| + primer_form_with(url: "/foo", model:) do |f| + render_inline_form(f) do |radio_form| + radio_form.advanced_radio_button_group( + name: :ultimate_answer, + label: "Ultimate answer", + **params + ) do |group| + group.radio_button( + value: "one", + label: "One", + caption: "Pick me", + icon: "icon_logo.svg" + ) + group.radio_button( + value: "two", + label: "Two", + caption: "Don't pick me", + icon: "icon_logo.svg" + ) + group.radio_button( + value: "three", + label: "Three", + icon: nil + ) + end + end + end + end + end + + subject(:rendered_form) do + render_form + page + end + + it "renders the fieldset" do + expect(rendered_form).to have_selector :fieldset, "Ultimate answer", role: "radiogroup" + end + + it "renders the radio buttons", :aggregate_failures do + expect(rendered_form).to have_field "One", type: :radio, fieldset: "Ultimate answer" + expect(rendered_form).to have_field "Two", type: :radio, fieldset: "Ultimate answer" + expect(rendered_form).to have_field "Three", type: :radio, fieldset: "Ultimate answer" + end + + it "renders icons" do + expect(rendered_form).to have_element :svg, count: 2, aria: { hidden: true } + end + + it "renders captions", :aggregate_failures do + expect(rendered_form).to have_css ".FormControl-caption", count: 2 + expect(rendered_form).to have_css ".FormControl-caption", text: "Pick me" + expect(rendered_form).to have_css ".FormControl-caption", text: "Don't pick me" + end + end + + describe "standard radio button group compatibility" do + specify "hidden radio button group", :aggregate_failures do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |radio_form| + radio_form.advanced_radio_button_group(name: :foobar, label: "Foobar", hidden: true) do |radio_group| + radio_group.radio_button(name: :foo, value: "Foo", label: "Foo") + end + end + end + end + + expect(page).to have_selector :fieldset, visible: :hidden + expect(page).to have_css ".FormControl-advanced-radio-wrap", visible: :hidden + end + + specify "disabled radio group disables constituent radios" do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |radio_form| + radio_form.advanced_radio_button_group(name: :foobar, label: "Foobar", disabled: true) do |radio_group| + radio_group.radio_button(name: :foo, value: "Foo", label: "Foo") + end + end + end + end + + expect(page).to have_css ".FormControl-advanced-radio-wrap input[disabled]" + end + + specify "radio can be individually disabled in group" do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |radio_form| + radio_form.advanced_radio_button_group(name: :foobar, label: "Foobar") do |radio_group| + radio_group.radio_button(name: :foo, value: "Foo", label: "Foo", disabled: true) + end + end + end + end + + expect(page).to have_css ".FormControl-advanced-radio-wrap input[disabled]" + end + end +end diff --git a/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb b/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb index 1b63d4e8a9f2..c12ed3c3fd39 100644 --- a/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb +++ b/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb @@ -113,6 +113,20 @@ include_examples "input class", Primer::Forms::Dsl::CheckBoxGroupInput it_behaves_like "supporting help texts" end + + describe "#advanced_radio_button_group" do + let(:field_group) { form_dsl.advanced_radio_button_group(name:, label:, **options) } + + include_examples "input class", Primer::OpenProject::Forms::Dsl::AdvancedRadioButtonGroupInput + it_behaves_like "supporting help texts" + end + + describe "#advanced_check_box_group" do + let(:field_group) { form_dsl.advanced_check_box_group(name:, label:, **options) } + + include_examples "input class", Primer::OpenProject::Forms::Dsl::AdvancedCheckBoxGroupInput + it_behaves_like "supporting help texts" + end end describe "#separator" do From d18e9135e88c46d14c135a4a0be5030d55fe1848 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 14 Nov 2025 13:38:48 +0000 Subject: [PATCH 03/18] [#69073] Rework Template Selector on Create form Updates the UI to show a "more visual" representation, making use of the newly-created "advanced" radio button group input. Extracts the inline form into `TemplateSelectForm` for easier reuse/ adaptability for upcoming project creation wizard. --- app/assets/images/templates/agile_project.svg | 11 ++ app/assets/images/templates/blank_project.svg | 4 + .../template_select_component.html.erb | 29 +---- .../projects/template_select_component.rb | 5 +- app/forms/projects/template_select_form.rb | 97 +++++++++++++++ config/locales/en.yml | 6 + .../template_select_component_spec.rb | 22 ++-- spec/features/projects/template_spec.rb | 6 +- .../projects/template_select_form_spec.rb | 110 ++++++++++++++++++ spec/support/forms/rendered_form.rb | 6 +- 10 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 app/assets/images/templates/agile_project.svg create mode 100644 app/assets/images/templates/blank_project.svg create mode 100644 app/forms/projects/template_select_form.rb create mode 100644 spec/forms/projects/template_select_form_spec.rb diff --git a/app/assets/images/templates/agile_project.svg b/app/assets/images/templates/agile_project.svg new file mode 100644 index 000000000000..7235e11d2b89 --- /dev/null +++ b/app/assets/images/templates/agile_project.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/app/assets/images/templates/blank_project.svg b/app/assets/images/templates/blank_project.svg new file mode 100644 index 000000000000..065ab75eab5c --- /dev/null +++ b/app/assets/images/templates/blank_project.svg @@ -0,0 +1,4 @@ + + + diff --git a/app/components/projects/template_select_component.html.erb b/app/components/projects/template_select_component.html.erb index c13df0203903..4bda836cc393 100644 --- a/app/components/projects/template_select_component.html.erb +++ b/app/components/projects/template_select_component.html.erb @@ -38,34 +38,7 @@ See COPYRIGHT and LICENSE files for more details. auto_submit_delay_value: 0 } ) do |f| - render( - OpPrimer::InsetBoxComponent.new(my: 3) - ) do - template_id_value = template_id - parent_id_value = parent_id - render_inline_form(f) do |form| - form.project_autocompleter( - name: :template_id, - label: I18n.t("create_project.template_label"), - autocomplete_options: { - focusDirectly: false, - dropdownPosition: "bottom", - inputValue: template_id_value, - placeholder: I18n.t("label_none_parentheses"), - filters: [ - { name: "user_action", operator: "=", values: ["projects/copy"] }, - { name: "templated", operator: "=", values: ["t"] } - ], - data: { - action: "change->auto-submit#submit", - "qa-field-name": "use_template" - } - } - ) - - form.hidden name: :parent_id, value: parent_id_value - end - end + render Projects::TemplateSelectForm.new(f, template_id:, parent_id:, current_user:) end end %> diff --git a/app/components/projects/template_select_component.rb b/app/components/projects/template_select_component.rb index 27d4ed96893b..4e90cc601c6f 100644 --- a/app/components/projects/template_select_component.rb +++ b/app/components/projects/template_select_component.rb @@ -30,11 +30,14 @@ module Projects class TemplateSelectComponent < ApplicationComponent + extend Dry::Initializer[undefined: false] include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable - options :template, :parent + option :template + option :parent, optional: true + option :current_user, default: -> { User.current } private diff --git a/app/forms/projects/template_select_form.rb b/app/forms/projects/template_select_form.rb new file mode 100644 index 000000000000..becf457d2b0a --- /dev/null +++ b/app/forms/projects/template_select_form.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 Projects + class TemplateSelectForm < ApplicationForm + extend Dry::Initializer[undefined: false] + include OpenProject::TextFormatting + + BLANK_VALUE = "" + private_constant :BLANK_VALUE + + option :template_id + option :parent_id, optional: true + option :current_user, default: -> { User.current } + + delegate :strip_tags, to: :@view_context + + form do |f| + f.advanced_radio_button_group( + name: :template_id, + label: I18n.t("create_project.template_label"), + scope_name_to_model: false, + data: { + action: "change->auto-submit#submit", + qa_field_name: "use_template" + } + ) do |group| + group.radio_button( + value: BLANK_VALUE, + label: I18n.t("create_project.blank_template.label"), + id: "template_id_blank", + caption: I18n.t("create_project.blank_template.description_html").html_safe, + icon: blank_image, + checked: template_id.blank? + ) + + available_templates.each do |template| + group.radio_button( + value: template.id, + label: template.name, + caption: format_caption(template.description), + icon: agile_image, # TODO: support customizable icons (OP #69068) + checked: template.id == template_id + ) + end + end + + f.hidden(name: :parent_id, value: parent_id, scope_name_to_model: false) if parent_id + end + + private + + def blank_image = "templates/blank_project.svg" + def agile_image = "templates/agile_project.svg" + + def available_templates + @available_templates ||= Project + .visible(current_user) + .active + .templated + .order(name: :asc) + end + + def format_caption(text) + return I18n.t("create_project.blank_description_html").html_safe if text.blank? + + render(Primer::Beta::Text.new) { strip_tags(format_text(text)) } + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index b82039ced599..4a9cfdd5ba19 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2217,6 +2217,12 @@ en: template_label: "Use template" copy_options: dependencies_label: "Copy from template" + blank_template: + label: "Blank project" + description_html: > + Start from scratch. Manually add project attributes, members and modules. + blank_description_html: > + No description provided. create_wiki_page: "Create new wiki page" create_wiki_page_button: "Wiki page" diff --git a/spec/components/projects/template_select_component_spec.rb b/spec/components/projects/template_select_component_spec.rb index 2344f7051a65..5c0c6d416974 100644 --- a/spec/components/projects/template_select_component_spec.rb +++ b/spec/components/projects/template_select_component_spec.rb @@ -31,26 +31,28 @@ require "rails_helper" RSpec.describe Projects::TemplateSelectComponent, type: :component do + def render_component(...) + render_inline(described_class.new(...)) + end + let(:template) { build_stubbed(:template_project) } + let(:current_user) { build_stubbed(:user) } - def render_component(**params) - render_inline(described_class.new(template:, **params)) - page - end + subject(:rendered_component) { render_component(template:, current_user:) } it "renders form" do - expect(render_component).to have_css "form" + expect(rendered_component).to have_element :form, method: "get" end - it "renders project autocompleter" do - expect(render_component).to have_element "opce-project-autocompleter", "data-input-name": "\"template_id\"" do |element| - expect(element["data-input-value"]).to eq template.id.to_s + it "registers Stimulus controller" do + expect(rendered_component).to have_element :form do |form| + expect(form["data-controller"]).to include "auto-submit" end end it "connects Stimulus controller actions" do - expect(render_component).to have_element "opce-project-autocompleter", "data-input-name": "\"template_id\"" do |element| - expect(element["data-action"]).to include "change->auto-submit#submit" + expect(rendered_component).to have_selector :fieldset, "Use template" do |fieldset| + expect(fieldset["data-action"]).to include "change->auto-submit#submit" end end end diff --git a/spec/features/projects/template_spec.rb b/spec/features/projects/template_spec.rb index 0cbfad433296..10e416c63d1d 100644 --- a/spec/features/projects/template_spec.rb +++ b/spec/features/projects/template_spec.rb @@ -85,8 +85,6 @@ create(:user, member_with_roles: { template => role }) end - let(:template_field) { FormFields::SelectFormField.new :use_template } - current_user do create(:user, member_with_roles: { template => role, other_project => role }, @@ -100,7 +98,7 @@ expect(page).to have_no_selector :fieldset, "Copy options" - template_field.select_option "My template" + choose "My template", fieldset: "Use template" # Only when a template is selected, the options are displayed. # Using this to know when the copy form has been fetched from the backend. @@ -110,8 +108,6 @@ # expect(page).to have_field "Name", with: "Foo bar" fill_in "Name", with: "Foo bar" - template_field.expect_selected "My template" - expect(page).to have_unchecked_field fieldset: "Notifications" # And allows to deselect copying the members. diff --git a/spec/forms/projects/template_select_form_spec.rb b/spec/forms/projects/template_select_form_spec.rb new file mode 100644 index 000000000000..53581b3b9ecf --- /dev/null +++ b/spec/forms/projects/template_select_form_spec.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. +#++ +# +require "spec_helper" + +RSpec.describe Projects::TemplateSelectForm, type: :forms do + include_context "with rendered form" + + let(:model) { create(:project) } + let(:params) { { template_id:, parent_id:, current_user: } } + let(:current_user) { create(:admin) } + + let(:template_id) { nil } + let(:parent_id) { nil } + + subject(:rendered_form) do + render_form + page + end + + context "with no templates" do + it "renders Blank Project radio button only" do + expect(rendered_form).to have_field type: :radio, count: 1, fieldset: "Use template" + expect(rendered_form).to have_field "Blank project", + type: :radio, + described_by: /^Start from scratch/ + end + end + + context "with templates" do + let!(:templates) do + [ + create(:template_project, name: "Agile", description: "**Great for beginners.**"), + create(:template_project, name: "SAF€", description: nil), + create(:template_project, name: "PRINCE", description: "## His Majesty's Choice.") + ] + end + + it "renders radio buttons for each template in addition to Blank Project", :aggregate_failures do + expect(rendered_form).to have_field type: :radio, count: 4, fieldset: "Use template" + expect(rendered_form).to have_field "Blank project", + type: :radio, + described_by: /^Start from scratch/ + expect(rendered_form).to have_field "Agile", + type: :radio, + described_by: /^Great for beginners\.$/ + expect(rendered_form).to have_field "SAF€", + type: :radio, + described_by: /^No description provided\.$/ + expect(rendered_form).to have_field "PRINCE", + type: :radio, + described_by: /^His Majesty's Choice\.$/ + end + + context "when template_id is nil" do + it "renders checked radio button for Blank Project" do + expect(rendered_form).to have_checked_field "Blank project", type: :radio + end + end + + context "when template_id is not nil" do + let(:template_id) { templates.last.id } + + it "renders checked radio button for given template" do + expect(rendered_form).to have_checked_field "PRINCE", type: :radio + end + end + end + + context "when parent_id is nil" do + it "does not render hidden parent_id field" do + expect(rendered_form).to have_no_field "parent_id", type: :hidden + end + end + + context "when parent_id is not nil" do + let(:parent_id) { 42 } + + it "renders hidden parent_id field" do + expect(rendered_form).to have_field "parent_id", type: :hidden, with: 42 + end + end +end diff --git a/spec/support/forms/rendered_form.rb b/spec/support/forms/rendered_form.rb index 613173475208..a31d3ff9df3d 100644 --- a/spec/support/forms/rendered_form.rb +++ b/spec/support/forms/rendered_form.rb @@ -30,10 +30,12 @@ RSpec.shared_context "with rendered form" do include ViewComponent::TestHelpers + let(:params) { {} } + def render_form - render_in_view_context(model, described_class) do |model, described_class| + render_in_view_context(model, described_class, params) do |model, described_class, params| primer_form_with(url: "/foo", model:) do |f| - render(described_class.new(f)) + render(described_class.new(f, **params)) end end end From fc0df1c941060dad830389520af7960449bb039c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 14 Nov 2025 13:45:45 +0000 Subject: [PATCH 04/18] Limit (clamp) UG template description to 3 lines Implements `line-clamp` mixin and utility classes. Inspiration: https://tailwindcss.com/docs/line-clamp --- app/forms/projects/template_select_form.rb | 4 +++- frontend/src/global_styles/content/_index.sass | 1 + frontend/src/global_styles/content/_text_utils.sass | 3 +++ frontend/src/global_styles/openproject/_mixins.sass | 6 ++++++ 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 frontend/src/global_styles/content/_text_utils.sass diff --git a/app/forms/projects/template_select_form.rb b/app/forms/projects/template_select_form.rb index becf457d2b0a..dd02d6d96649 100644 --- a/app/forms/projects/template_select_form.rb +++ b/app/forms/projects/template_select_form.rb @@ -91,7 +91,9 @@ def available_templates def format_caption(text) return I18n.t("create_project.blank_description_html").html_safe if text.blank? - render(Primer::Beta::Text.new) { strip_tags(format_text(text)) } + render(Primer::Beta::Text.new(classes: %w[line-clamp-3 lh-default])) do + strip_tags(format_text(text)) + end end end end diff --git a/frontend/src/global_styles/content/_index.sass b/frontend/src/global_styles/content/_index.sass index c8400e513f22..94599def3714 100644 --- a/frontend/src/global_styles/content/_index.sass +++ b/frontend/src/global_styles/content/_index.sass @@ -76,6 +76,7 @@ @import activity_list @import activity_days @import hierachy_custom_field_layout +@import text_utils @import menus/menu_blocks @import editor/index diff --git a/frontend/src/global_styles/content/_text_utils.sass b/frontend/src/global_styles/content/_text_utils.sass new file mode 100644 index 000000000000..0e5b1cddc6e8 --- /dev/null +++ b/frontend/src/global_styles/content/_text_utils.sass @@ -0,0 +1,3 @@ +@for $i from 1 through 6 + .line-clamp-#{$i} + @include line-clamp($i) diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass index 1a507392e8f9..032237b35fc9 100644 --- a/frontend/src/global_styles/openproject/_mixins.sass +++ b/frontend/src/global_styles/openproject/_mixins.sass @@ -388,3 +388,9 @@ $scrollbar-size: 10px @media (hover: hover) &:hover border-color: var(--borderColor-default) !important + +@mixin line-clamp($value) + display: -webkit-box + -webkit-line-clamp: $value + -webkit-box-orient: vertical + overflow: hidden From cc0c23976f52a979a80b1ec47924cc018c55e4a3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 15 Nov 2025 21:14:26 -0300 Subject: [PATCH 05/18] Pass :current_user explicitly to component --- app/views/projects/new.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb index f0d493df4a4c..be93cc17c42a 100644 --- a/app/views/projects/new.html.erb +++ b/app/views/projects/new.html.erb @@ -37,7 +37,8 @@ See COPYRIGHT and LICENSE files for more details. <%= render Projects::TemplateSelectComponent.new( template: @template, - parent: @parent + parent: @parent, + current_user: ) %> From 6f69140470534544433dfd71806ec12701e02eaa Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 15 Nov 2025 21:51:32 -0300 Subject: [PATCH 06/18] Indent caption, aligning with label text This aligns caption styling closer with standard Primer radio button and checkbox groups. --- frontend/src/global_styles/openproject/_forms.sass | 9 +++------ .../open_project/forms/advanced_check_box.html.erb | 6 ++---- .../open_project/forms/advanced_radio_button.html.erb | 6 ++---- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/frontend/src/global_styles/openproject/_forms.sass b/frontend/src/global_styles/openproject/_forms.sass index 17180a8c977e..2ad2073b2b27 100644 --- a/frontend/src/global_styles/openproject/_forms.sass +++ b/frontend/src/global_styles/openproject/_forms.sass @@ -211,15 +211,12 @@ input[type="number"] .FormControl-advanced-checkbox-content, .FormControl-advanced-radio-content display: grid - grid-template-columns: auto min-content + grid-template-columns: min-content auto min-content gap: var(--base-size-8) padding: var(--base-size-12) -.FormControl-advanced-checkbox-label-row, -.FormControl-advanced-radio-label-row - display: inline-grid - grid-template-columns: min-content auto - gap: var(--base-size-8) +.FormControl-advanced-checkbox-label-text, +.FormControl-advanced-radio-label-text color: var(--fgColor-accent) .FormControl-advanced-checkbox-icon, diff --git a/lib/primer/open_project/forms/advanced_check_box.html.erb b/lib/primer/open_project/forms/advanced_check_box.html.erb index fe8d216ad43a..6fd082f30b74 100644 --- a/lib/primer/open_project/forms/advanced_check_box.html.erb +++ b/lib/primer/open_project/forms/advanced_check_box.html.erb @@ -1,11 +1,9 @@ <%= content_tag(:div, class: "FormControl-advanced-checkbox-wrap", hidden: @input.hidden?) do %> <%= builder.label(@input.name, **@input.label_arguments) do %>
+ <%= builder.check_box(@input.name, @input.input_arguments, checked_value, unchecked_value) %>
-
- <%= builder.check_box(@input.name, @input.input_arguments, checked_value, unchecked_value) %> - <%= @input.label %> -
+
<%= @input.label %>
<% if @input.form_control? %>
<%= render(Caption.new(input: @input)) %> diff --git a/lib/primer/open_project/forms/advanced_radio_button.html.erb b/lib/primer/open_project/forms/advanced_radio_button.html.erb index fd1bdb62a478..2d82b6c81a8e 100644 --- a/lib/primer/open_project/forms/advanced_radio_button.html.erb +++ b/lib/primer/open_project/forms/advanced_radio_button.html.erb @@ -1,11 +1,9 @@ <%= content_tag(:div, class: "FormControl-advanced-radio-wrap", hidden: @input.hidden?) do %> <%= builder.label(@input.name, value: @input.value, **@input.label_arguments) do %>
+ <%= builder.radio_button(@input.name, @input.value, **@input.input_arguments) %>
-
- <%= builder.radio_button(@input.name, @input.value, **@input.input_arguments) %> - <%= @input.label %> -
+
<%= @input.label %>
<% if @input.form_control? %>
<%= render(Caption.new(input: @input)) %> From de219c8a5b5534d66756dd07bcaa229e65a93473 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Nov 2025 01:27:24 -0300 Subject: [PATCH 07/18] Remove redundant name parameter from radio_button calls in specs (#21049) * Initial plan * Remove redundant name parameter from radio_button calls in spec Co-authored-by: myabc <755+myabc@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: myabc <755+myabc@users.noreply.github.com> --- .../open_project/forms/advanced_radio_button_group_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb b/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb index 19b5ece9cca6..0702134160ac 100644 --- a/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb +++ b/spec/lib/primer/open_project/forms/advanced_radio_button_group_spec.rb @@ -101,7 +101,7 @@ def render_form primer_form_with(url: "/foo") do |f| render_inline_form(f) do |radio_form| radio_form.advanced_radio_button_group(name: :foobar, label: "Foobar", hidden: true) do |radio_group| - radio_group.radio_button(name: :foo, value: "Foo", label: "Foo") + radio_group.radio_button(value: "Foo", label: "Foo") end end end @@ -116,7 +116,7 @@ def render_form primer_form_with(url: "/foo") do |f| render_inline_form(f) do |radio_form| radio_form.advanced_radio_button_group(name: :foobar, label: "Foobar", disabled: true) do |radio_group| - radio_group.radio_button(name: :foo, value: "Foo", label: "Foo") + radio_group.radio_button(value: "Foo", label: "Foo") end end end @@ -130,7 +130,7 @@ def render_form primer_form_with(url: "/foo") do |f| render_inline_form(f) do |radio_form| radio_form.advanced_radio_button_group(name: :foobar, label: "Foobar") do |radio_group| - radio_group.radio_button(name: :foo, value: "Foo", label: "Foo", disabled: true) + radio_group.radio_button(value: "Foo", label: "Foo", disabled: true) end end end From 5e7841fbaeeaa46ec1e42d6202973a0e4f7c5429 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 10 Nov 2025 20:53:45 +0100 Subject: [PATCH 08/18] reorder allowed types to put them in order portfolio > program > project --- app/components/projects/index_sub_header_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/projects/index_sub_header_component.rb b/app/components/projects/index_sub_header_component.rb index 992efa4bbcde..efc035df8dc4 100644 --- a/app/components/projects/index_sub_header_component.rb +++ b/app/components/projects/index_sub_header_component.rb @@ -88,9 +88,9 @@ def new_workspace_label(type) def allowed_new_workspace_types allowed_types = [] - allowed_types << Project.workspace_types[:project] if @current_user.allowed_globally?(:add_project) allowed_types << Project.workspace_types[:portfolio] if @current_user.allowed_globally?(:add_portfolios) && OpenProject::FeatureDecisions.portfolio_models_active? allowed_types << Project.workspace_types[:program] if @current_user.allowed_globally?(:add_programs) && OpenProject::FeatureDecisions.portfolio_models_active? + allowed_types << Project.workspace_types[:project] if @current_user.allowed_globally?(:add_project) allowed_types end From 2b134e92f534f553c60418e30abcdd811b3a885d Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 10 Nov 2025 20:58:58 +0100 Subject: [PATCH 09/18] cleanup using tap and memoization Projects::IndexSubHeaderComponent#allowed_new_workspace_types --- .../projects/index_sub_header_component.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/components/projects/index_sub_header_component.rb b/app/components/projects/index_sub_header_component.rb index efc035df8dc4..59e1df8cc8c5 100644 --- a/app/components/projects/index_sub_header_component.rb +++ b/app/components/projects/index_sub_header_component.rb @@ -87,11 +87,13 @@ def new_workspace_label(type) end def allowed_new_workspace_types - allowed_types = [] - allowed_types << Project.workspace_types[:portfolio] if @current_user.allowed_globally?(:add_portfolios) && OpenProject::FeatureDecisions.portfolio_models_active? - allowed_types << Project.workspace_types[:program] if @current_user.allowed_globally?(:add_programs) && OpenProject::FeatureDecisions.portfolio_models_active? - allowed_types << Project.workspace_types[:project] if @current_user.allowed_globally?(:add_project) - allowed_types + @allowed_new_workspace_types ||= [].tap do |types| + if OpenProject::FeatureDecisions.portfolio_models_active? + types << Project.workspace_types[:portfolio] if @current_user.allowed_globally?(:add_portfolios) + types << Project.workspace_types[:program] if @current_user.allowed_globally?(:add_programs) + end + types << Project.workspace_types[:project] if @current_user.allowed_globally?(:add_project) + end end def for_a_single_new_allowed_type From 65b48bd3d4f765944b2afb7dab99fb43a9c207a4 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Wed, 12 Nov 2025 18:11:57 +0100 Subject: [PATCH 10/18] remove unnecessary usage of Project.workspace_types[:xxx] --- .../projects/index_sub_header_component.rb | 17 ++++------- app/helpers/workspace_helper.rb | 28 ++++++++----------- app/seeders/demo_data/project_seeder.rb | 2 +- .../development_data/projects_seeder.rb | 2 +- lib/api/v3/projects/create_form_api.rb | 3 +- lib/api/v3/projects/projects_api.rb | 3 +- 6 files changed, 21 insertions(+), 34 deletions(-) diff --git a/app/components/projects/index_sub_header_component.rb b/app/components/projects/index_sub_header_component.rb index 59e1df8cc8c5..20eb6e169c24 100644 --- a/app/components/projects/index_sub_header_component.rb +++ b/app/components/projects/index_sub_header_component.rb @@ -72,14 +72,9 @@ def clear_button_id end def new_workspace_path(type) - case type - when Project.workspace_types[:project] - new_project_path - when Project.workspace_types[:portfolio] - new_portfolio_path - when Project.workspace_types[:program] - new_program_path - end + return unless Project.workspace_types.key?(type) + + url_for([:new, type.to_sym]) end def new_workspace_label(type) @@ -89,10 +84,10 @@ def new_workspace_label(type) def allowed_new_workspace_types @allowed_new_workspace_types ||= [].tap do |types| if OpenProject::FeatureDecisions.portfolio_models_active? - types << Project.workspace_types[:portfolio] if @current_user.allowed_globally?(:add_portfolios) - types << Project.workspace_types[:program] if @current_user.allowed_globally?(:add_programs) + types << "portfolio" if @current_user.allowed_globally?(:add_portfolios) + types << "program" if @current_user.allowed_globally?(:add_programs) end - types << Project.workspace_types[:project] if @current_user.allowed_globally?(:add_project) + types << "project" if @current_user.allowed_globally?(:add_project) end end diff --git a/app/helpers/workspace_helper.rb b/app/helpers/workspace_helper.rb index 86687dd880ac..e01f142790f9 100644 --- a/app/helpers/workspace_helper.rb +++ b/app/helpers/workspace_helper.rb @@ -29,24 +29,18 @@ #++ module WorkspaceHelper + WORKSPACE_ICON_MAPPING = Hash.new(:project) + .with_indifferent_access + .merge( + portfolio: :briefcase, + program: :"project-roadmap" + ) + def new_workspace_title(workspace) - if workspace.project? - I18n.t(:label_project_new) - elsif workspace.portfolio? - I18n.t(:label_portfolio_new) - elsif workspace.program? - I18n.t(:label_program_new) - end - end + return unless Project.workspace_types.key?(workspace.workspace_type) - def workspace_icon(type) - case type - when Project.workspace_types[:portfolio] - :briefcase - when Project.workspace_types[:program] - :"project-roadmap" - else - :project - end + I18n.t(:"label_#{workspace.workspace_type}_new") end + + def workspace_icon(type) = WORKSPACE_ICON_MAPPING[type] end diff --git a/app/seeders/demo_data/project_seeder.rb b/app/seeders/demo_data/project_seeder.rb index 2fe412e0e208..6473058c294a 100644 --- a/app/seeders/demo_data/project_seeder.rb +++ b/app/seeders/demo_data/project_seeder.rb @@ -164,7 +164,7 @@ def project_attributes # rubocop:disable Metrics/AbcSize enabled_module_names: project_data.lookup("modules"), types: Type.all, parent: Project.find_by(identifier: project_data.lookup("parent")), - workspace_type: Project.workspace_types[:project] + workspace_type: "project" } end end diff --git a/app/seeders/development_data/projects_seeder.rb b/app/seeders/development_data/projects_seeder.rb index 2e71fae45d83..749506389b48 100644 --- a/app/seeders/development_data/projects_seeder.rb +++ b/app/seeders/development_data/projects_seeder.rb @@ -113,7 +113,7 @@ def project_data(identifier) identifier:, enabled_module_names: project_modules, types: Type.all, - workspace_type: Project.workspace_types[:project] + workspace_type: "project" } end diff --git a/lib/api/v3/projects/create_form_api.rb b/lib/api/v3/projects/create_form_api.rb index 168c77225c93..fe8b6d839db3 100644 --- a/lib/api/v3/projects/create_form_api.rb +++ b/lib/api/v3/projects/create_form_api.rb @@ -39,8 +39,7 @@ class CreateFormAPI < ::API::OpenProjectAPI post &::API::V3::Utilities::Endpoints::CreateForm.new(model: Project, params_modifier: ->(attributes) { - attributes[:workspace_type] = Project.workspace_types[:project] - attributes + attributes.merge!(workspace_type: "project") }) .mount end diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index f30268b0946f..e3ecc90d1543 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -42,8 +42,7 @@ class ProjectsAPI < ::API::OpenProjectAPI post &::API::V3::Utilities::Endpoints::Create.new(model: Project, params_modifier: ->(attributes) { - attributes[:workspace_type] = Project.workspace_types[:project] - attributes + attributes.merge!(workspace_type: "project") }) .mount From 6aa6e008527192fda8784c285def370c4e28084e Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Wed, 12 Nov 2025 18:30:44 +0100 Subject: [PATCH 11/18] keep workspace type url when selecting template --- .../template_select_component.html.erb | 2 +- .../projects/template_select_component.rb | 11 ++++ app/views/projects/new.html.erb | 1 + .../template_select_component_spec.rb | 57 ++++++++++++++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/app/components/projects/template_select_component.html.erb b/app/components/projects/template_select_component.html.erb index 4bda836cc393..840956d072d9 100644 --- a/app/components/projects/template_select_component.html.erb +++ b/app/components/projects/template_select_component.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper do settings_primer_form_with( - url: new_project_path, + url: new_workspace_path, method: :get, data: { turbo_frame: Projects::NewComponent.wrapper_key, diff --git a/app/components/projects/template_select_component.rb b/app/components/projects/template_select_component.rb index 4e90cc601c6f..be25bdb18426 100644 --- a/app/components/projects/template_select_component.rb +++ b/app/components/projects/template_select_component.rb @@ -35,12 +35,23 @@ class TemplateSelectComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable + option :project option :template option :parent, optional: true option :current_user, default: -> { User.current } private + def new_workspace_path + workspace_type = if Project.workspace_types.key?(project.workspace_type) + project.workspace_type.to_sym + else + :project + end + + url_for([:new, workspace_type]) + end + def template_id = template&.id def parent_id = parent&.id end diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb index be93cc17c42a..eade24d70fe3 100644 --- a/app/views/projects/new.html.erb +++ b/app/views/projects/new.html.erb @@ -36,6 +36,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render Projects::TemplateSelectComponent.new( + project: @new_project, template: @template, parent: @parent, current_user: diff --git a/spec/components/projects/template_select_component_spec.rb b/spec/components/projects/template_select_component_spec.rb index 5c0c6d416974..ae78ff2f3548 100644 --- a/spec/components/projects/template_select_component_spec.rb +++ b/spec/components/projects/template_select_component_spec.rb @@ -35,15 +35,70 @@ def render_component(...) render_inline(described_class.new(...)) end + let(:project) { Project.new } let(:template) { build_stubbed(:template_project) } let(:current_user) { build_stubbed(:user) } - subject(:rendered_component) { render_component(template:, current_user:) } + subject(:rendered_component) { render_component(project:, template:, current_user:) } it "renders form" do expect(rendered_component).to have_element :form, method: "get" end + describe "action" do + let(:project) { Project.new(workspace_type:) } + + context "when workspace type is not set" do + let(:workspace_type) { nil } + + it "sets action to create project" do + expect(rendered_component).to have_element :form, method: "get" do |form| + expect(form["action"]).to eq "/projects/new" + end + end + end + + context "when workspace type set to unknown value" do + let(:workspace_type) { :unknown } + + it "sets action to create project" do + expect(rendered_component).to have_element :form, method: "get" do |form| + expect(form["action"]).to eq "/projects/new" + end + end + end + + context "when workspace type is set to project" do + let(:workspace_type) { :project } + + it "sets action to create project" do + expect(rendered_component).to have_element :form, method: "get" do |form| + expect(form["action"]).to eq "/projects/new" + end + end + end + + context "when workspace type is set to program" do + let(:workspace_type) { :program } + + it "sets action to create project" do + expect(rendered_component).to have_element :form, method: "get" do |form| + expect(form["action"]).to eq "/programs/new" + end + end + end + + context "when workspace type is set to portfolio" do + let(:workspace_type) { :portfolio } + + it "sets action to create project" do + expect(rendered_component).to have_element :form, method: "get" do |form| + expect(form["action"]).to eq "/portfolios/new" + end + end + end + end + it "registers Stimulus controller" do expect(rendered_component).to have_element :form do |form| expect(form["data-controller"]).to include "auto-submit" From 4cb4ed770e0ccf0152722238a234ab2dd1c2eccc Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 17 Nov 2025 20:12:28 +0100 Subject: [PATCH 12/18] Explicit value for key :project instead of defaulting to it Co-authored-by: dombesz <83396+dombesz@users.noreply.github.com> --- app/helpers/workspace_helper.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/helpers/workspace_helper.rb b/app/helpers/workspace_helper.rb index e01f142790f9..4ca918f11a2d 100644 --- a/app/helpers/workspace_helper.rb +++ b/app/helpers/workspace_helper.rb @@ -29,12 +29,11 @@ #++ module WorkspaceHelper - WORKSPACE_ICON_MAPPING = Hash.new(:project) - .with_indifferent_access - .merge( - portfolio: :briefcase, - program: :"project-roadmap" - ) + WORKSPACE_ICON_MAPPING = { + project: :project, + portfolio: :briefcase, + program: :"project-roadmap" + }.with_indifferent_access.freeze def new_workspace_title(workspace) return unless Project.workspace_types.key?(workspace.workspace_type) From ae50386a42ac056bb471a95d249c378f16e127cb Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:53:36 +0200 Subject: [PATCH 13/18] Fix bug on calling workspace_icon with the wrong argument, fix specs. --- modules/grids/app/components/grids/widgets/subitems.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/grids/app/components/grids/widgets/subitems.html.erb b/modules/grids/app/components/grids/widgets/subitems.html.erb index 711cd7d11f91..d830bf56ba35 100644 --- a/modules/grids/app/components/grids/widgets/subitems.html.erb +++ b/modules/grids/app/components/grids/widgets/subitems.html.erb @@ -41,7 +41,7 @@ See COPYRIGHT and LICENSE files for more details. row.with_column do render(Primer::Beta::Text.new(color: :subtle)) do concat render Primer::Beta::Octicon.new( - icon: helpers.workspace_icon(child), + icon: helpers.workspace_icon(child.workspace_type), mr: 1, color: :subtle, "aria-label": child.workspace_label From e858346d5b53ab5d417f1a3e1281dd0f586eafda Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:57:29 +0200 Subject: [PATCH 14/18] Use the apropriate project, portfolio, program name for the blank project in the template selector. --- .../template_select_component.html.erb | 2 +- app/forms/projects/template_select_form.rb | 17 ++++++++++++++-- config/locales/en.yml | 12 +++++++++++ .../projects/template_select_form_spec.rb | 20 ++++++++++++------- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/app/components/projects/template_select_component.html.erb b/app/components/projects/template_select_component.html.erb index 840956d072d9..8c5bac513008 100644 --- a/app/components/projects/template_select_component.html.erb +++ b/app/components/projects/template_select_component.html.erb @@ -38,7 +38,7 @@ See COPYRIGHT and LICENSE files for more details. auto_submit_delay_value: 0 } ) do |f| - render Projects::TemplateSelectForm.new(f, template_id:, parent_id:, current_user:) + render Projects::TemplateSelectForm.new(f, template_id:, parent_id:, workspace_type: project.workspace_type, current_user:) end end %> diff --git a/app/forms/projects/template_select_form.rb b/app/forms/projects/template_select_form.rb index dd02d6d96649..8d689242d3e3 100644 --- a/app/forms/projects/template_select_form.rb +++ b/app/forms/projects/template_select_form.rb @@ -38,6 +38,7 @@ class TemplateSelectForm < ApplicationForm option :template_id option :parent_id, optional: true + option :workspace_type option :current_user, default: -> { User.current } delegate :strip_tags, to: :@view_context @@ -54,9 +55,9 @@ class TemplateSelectForm < ApplicationForm ) do |group| group.radio_button( value: BLANK_VALUE, - label: I18n.t("create_project.blank_template.label"), + label: blank_template_label, id: "template_id_blank", - caption: I18n.t("create_project.blank_template.description_html").html_safe, + caption: blank_template_caption, icon: blank_image, checked: template_id.blank? ) @@ -95,5 +96,17 @@ def format_caption(text) strip_tags(format_text(text)) end end + + def blank_template_label + return unless Project.workspace_types.key?(workspace_type) + + I18n.t("create_#{workspace_type}.blank_template.label") + end + + def blank_template_caption + return unless Project.workspace_types.key?(workspace_type) + + I18n.t("create_#{workspace_type}.blank_template.description_html").html_safe + end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a9cfdd5ba19..d2d95bc80998 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2224,6 +2224,18 @@ en: blank_description_html: > No description provided. + create_portfolio: + blank_template: + label: "Blank portfolio" + description_html: > + Start from scratch. Manually add portfolio attributes, members and modules. + + create_program: + blank_template: + label: "Blank program" + description_html: > + Start from scratch. Manually add program attributes, members and modules. + create_wiki_page: "Create new wiki page" create_wiki_page_button: "Wiki page" diff --git a/spec/forms/projects/template_select_form_spec.rb b/spec/forms/projects/template_select_form_spec.rb index 53581b3b9ecf..ac5b6f7fb817 100644 --- a/spec/forms/projects/template_select_form_spec.rb +++ b/spec/forms/projects/template_select_form_spec.rb @@ -34,7 +34,7 @@ include_context "with rendered form" let(:model) { create(:project) } - let(:params) { { template_id:, parent_id:, current_user: } } + let(:params) { { template_id:, parent_id:, current_user:, workspace_type: model.workspace_type } } let(:current_user) { create(:admin) } let(:template_id) { nil } @@ -46,11 +46,17 @@ end context "with no templates" do - it "renders Blank Project radio button only" do - expect(rendered_form).to have_field type: :radio, count: 1, fieldset: "Use template" - expect(rendered_form).to have_field "Blank project", - type: :radio, - described_by: /^Start from scratch/ + %i[project portfolio program].each do |workspace_type| + context "when workspace_type is #{workspace_type}" do + let(:model) { create(:project, workspace_type:) } + + it "renders Blank #{workspace_type.to_s.capitalize} radio button only" do + expect(rendered_form).to have_field type: :radio, count: 1, fieldset: "Use template" + expect(rendered_form).to have_field "Blank #{workspace_type}", + type: :radio, + described_by: /^Start from scratch.*#{workspace_type}/ + end + end end end @@ -67,7 +73,7 @@ expect(rendered_form).to have_field type: :radio, count: 4, fieldset: "Use template" expect(rendered_form).to have_field "Blank project", type: :radio, - described_by: /^Start from scratch/ + described_by: /^Start from scratch.*project/ expect(rendered_form).to have_field "Agile", type: :radio, described_by: /^Great for beginners\.$/ From a4fa97dbcbf292abf37bfa594cdd51220b3b36a4 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:46:06 +0200 Subject: [PATCH 15/18] Remove unnecessary .html_safe methods. --- app/forms/projects/template_select_form.rb | 4 ++-- config/locales/en.yml | 12 ++++-------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/forms/projects/template_select_form.rb b/app/forms/projects/template_select_form.rb index 8d689242d3e3..24f402a053a8 100644 --- a/app/forms/projects/template_select_form.rb +++ b/app/forms/projects/template_select_form.rb @@ -90,7 +90,7 @@ def available_templates end def format_caption(text) - return I18n.t("create_project.blank_description_html").html_safe if text.blank? + return I18n.t("create_project.blank_description") if text.blank? render(Primer::Beta::Text.new(classes: %w[line-clamp-3 lh-default])) do strip_tags(format_text(text)) @@ -106,7 +106,7 @@ def blank_template_label def blank_template_caption return unless Project.workspace_types.key?(workspace_type) - I18n.t("create_#{workspace_type}.blank_template.description_html").html_safe + I18n.t("create_#{workspace_type}.blank_template.description") end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index d2d95bc80998..c55b8e7d7a83 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2219,22 +2219,18 @@ en: dependencies_label: "Copy from template" blank_template: label: "Blank project" - description_html: > - Start from scratch. Manually add project attributes, members and modules. - blank_description_html: > - No description provided. + description: Start from scratch. Manually add project attributes, members and modules. + blank_description: No description provided. create_portfolio: blank_template: label: "Blank portfolio" - description_html: > - Start from scratch. Manually add portfolio attributes, members and modules. + description: Start from scratch. Manually add portfolio attributes, members and modules. create_program: blank_template: label: "Blank program" - description_html: > - Start from scratch. Manually add program attributes, members and modules. + description: Start from scratch. Manually add program attributes, members and modules. create_wiki_page: "Create new wiki page" create_wiki_page_button: "Wiki page" From c516e42c15d40998f80d3fe7e6a345bfe0266660 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 18 Nov 2025 11:45:53 +0100 Subject: [PATCH 16/18] Remove icon from the template selector --- app/assets/images/templates/agile_project.svg | 11 ----------- app/assets/images/templates/blank_project.svg | 4 ---- app/forms/projects/template_select_form.rb | 5 ----- 3 files changed, 20 deletions(-) delete mode 100644 app/assets/images/templates/agile_project.svg delete mode 100644 app/assets/images/templates/blank_project.svg diff --git a/app/assets/images/templates/agile_project.svg b/app/assets/images/templates/agile_project.svg deleted file mode 100644 index 7235e11d2b89..000000000000 --- a/app/assets/images/templates/agile_project.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - diff --git a/app/assets/images/templates/blank_project.svg b/app/assets/images/templates/blank_project.svg deleted file mode 100644 index 065ab75eab5c..000000000000 --- a/app/assets/images/templates/blank_project.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/app/forms/projects/template_select_form.rb b/app/forms/projects/template_select_form.rb index 24f402a053a8..9bc6ce2c1ce6 100644 --- a/app/forms/projects/template_select_form.rb +++ b/app/forms/projects/template_select_form.rb @@ -58,7 +58,6 @@ class TemplateSelectForm < ApplicationForm label: blank_template_label, id: "template_id_blank", caption: blank_template_caption, - icon: blank_image, checked: template_id.blank? ) @@ -67,7 +66,6 @@ class TemplateSelectForm < ApplicationForm value: template.id, label: template.name, caption: format_caption(template.description), - icon: agile_image, # TODO: support customizable icons (OP #69068) checked: template.id == template_id ) end @@ -78,9 +76,6 @@ class TemplateSelectForm < ApplicationForm private - def blank_image = "templates/blank_project.svg" - def agile_image = "templates/agile_project.svg" - def available_templates @available_templates ||= Project .visible(current_user) From 79bf18cae2e8bdf282c8eab88c8a9763d787451a Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 18 Nov 2025 11:46:42 +0100 Subject: [PATCH 17/18] Move component specific styles to an own file --- frontend/src/global_styles/openproject.sass | 1 + .../src/global_styles/openproject/_forms.sass | 51 ------------------- lib/primer/open_project/forms/_index.sass | 1 + .../forms/advanced_form_group.sass | 50 ++++++++++++++++++ 4 files changed, 52 insertions(+), 51 deletions(-) create mode 100644 lib/primer/open_project/forms/_index.sass create mode 100644 lib/primer/open_project/forms/advanced_form_group.sass diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass index 3716309a004a..d8164407754b 100644 --- a/frontend/src/global_styles/openproject.sass +++ b/frontend/src/global_styles/openproject.sass @@ -31,3 +31,4 @@ // Component specific Styles @import "../../../app/components/_index.sass" @import "../../../app/forms/_index.sass" +@import "../../../lib/primer/open_project/forms/_index.sass" diff --git a/frontend/src/global_styles/openproject/_forms.sass b/frontend/src/global_styles/openproject/_forms.sass index 2ad2073b2b27..38c0f64a858d 100644 --- a/frontend/src/global_styles/openproject/_forms.sass +++ b/frontend/src/global_styles/openproject/_forms.sass @@ -171,54 +171,3 @@ input[type="number"] &::-webkit-outer-spin-button -webkit-appearance: none margin: 0 - -.FormControl-advanced-check-group-wrap, -.FormControl-advanced-radio-group-wrap - & fieldset - padding: 0 - margin: 0 - border: 0 - -.FormControl-advanced-check-group-list, -.FormControl-advanced-radio-group-list - display: grid - grid-template-columns: repeat(2, 1fr) - gap: var(--base-size-12) var(--base-size-16) - - @media screen and (max-width: $breakpoint-sm) - grid-template-columns: 1fr - -.FormControl-advanced-checkbox-wrap, -.FormControl-advanced-radio-wrap - display: flex - background-color: var(--bgColor-inset) - border: var(--borderWidth-thin, 1px) solid var(--borderColor-default) - border-radius: var(--borderRadius-medium) - - &:has(input:hover) - background-color: var(--bgColor-accent-muted) - border-color: var(--borderColor-accent-muted) - - &:has(input:checked) - background-color: var(--bgColor-accent-muted) - border-color: var(--control-checked-borderColor-rest) - - & .FormControl-label - display: block - flex: 1 - cursor: pointer - -.FormControl-advanced-checkbox-content, -.FormControl-advanced-radio-content - display: grid - grid-template-columns: min-content auto min-content - gap: var(--base-size-8) - padding: var(--base-size-12) - -.FormControl-advanced-checkbox-label-text, -.FormControl-advanced-radio-label-text - color: var(--fgColor-accent) - -.FormControl-advanced-checkbox-icon, -.FormControl-advanced-radio-icon - fill: var(--fgColor-accent) diff --git a/lib/primer/open_project/forms/_index.sass b/lib/primer/open_project/forms/_index.sass new file mode 100644 index 000000000000..4eee9f5aee3a --- /dev/null +++ b/lib/primer/open_project/forms/_index.sass @@ -0,0 +1 @@ +@import "advanced_form_group" diff --git a/lib/primer/open_project/forms/advanced_form_group.sass b/lib/primer/open_project/forms/advanced_form_group.sass new file mode 100644 index 000000000000..2b9236d6b24a --- /dev/null +++ b/lib/primer/open_project/forms/advanced_form_group.sass @@ -0,0 +1,50 @@ +.FormControl-advanced-check-group-wrap, +.FormControl-advanced-radio-group-wrap + & fieldset + padding: 0 + margin: 0 + border: 0 + +.FormControl-advanced-check-group-list, +.FormControl-advanced-radio-group-list + display: grid + grid-template-columns: repeat(2, 1fr) + gap: var(--base-size-12) var(--base-size-16) + + @media screen and (max-width: $breakpoint-sm) + grid-template-columns: 1fr + +.FormControl-advanced-checkbox-wrap, +.FormControl-advanced-radio-wrap + display: flex + background-color: var(--bgColor-inset) + border: var(--borderWidth-thin, 1px) solid var(--borderColor-default) + border-radius: var(--borderRadius-medium) + + &:has(input:hover) + background-color: var(--bgColor-accent-muted) + border-color: var(--borderColor-accent-muted) + + &:has(input:checked) + background-color: var(--bgColor-accent-muted) + border-color: var(--control-checked-borderColor-rest) + + & .FormControl-label + display: block + flex: 1 + cursor: pointer + +.FormControl-advanced-checkbox-content, +.FormControl-advanced-radio-content + display: grid + grid-template-columns: min-content auto min-content + gap: var(--base-size-8) + padding: var(--base-size-12) + +.FormControl-advanced-checkbox-label-text, +.FormControl-advanced-radio-label-text + color: var(--fgColor-accent) + +.FormControl-advanced-checkbox-icon, +.FormControl-advanced-radio-icon + fill: var(--fgColor-accent) From 7c6ef3536c07dc15ff465cb9742da0973659c3a0 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:07:10 +0200 Subject: [PATCH 18/18] Update specs to use accessible_description from capybara. --- spec/forms/projects/template_select_form_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/forms/projects/template_select_form_spec.rb b/spec/forms/projects/template_select_form_spec.rb index ac5b6f7fb817..bd78ac63b171 100644 --- a/spec/forms/projects/template_select_form_spec.rb +++ b/spec/forms/projects/template_select_form_spec.rb @@ -54,7 +54,7 @@ expect(rendered_form).to have_field type: :radio, count: 1, fieldset: "Use template" expect(rendered_form).to have_field "Blank #{workspace_type}", type: :radio, - described_by: /^Start from scratch.*#{workspace_type}/ + accessible_description: /^Start from scratch.*#{workspace_type}/ end end end @@ -73,16 +73,16 @@ expect(rendered_form).to have_field type: :radio, count: 4, fieldset: "Use template" expect(rendered_form).to have_field "Blank project", type: :radio, - described_by: /^Start from scratch.*project/ + accessible_description: /^Start from scratch.*project/ expect(rendered_form).to have_field "Agile", type: :radio, - described_by: /^Great for beginners\.$/ + accessible_description: /^Great for beginners\.$/ expect(rendered_form).to have_field "SAF€", type: :radio, - described_by: /^No description provided\.$/ + accessible_description: /^No description provided\.$/ expect(rendered_form).to have_field "PRINCE", type: :radio, - described_by: /^His Majesty's Choice\.$/ + accessible_description: /^His Majesty's Choice\.$/ end context "when template_id is nil" do