From 88a29a0aff103f2b2224f606ea91be59a8a774f2 Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 19 May 2026 14:59:09 +0200 Subject: [PATCH] Turn checkbox filters into toggle switch. wp/74380 --- app/forms/filters/inputs/boolean_form.rb | 30 ++++------- .../dynamic/filter/filters-form.controller.ts | 6 +++ .../filter/segmented-control.controller.ts | 51 +++++++++++++++++++ .../open_project/forms/dsl/input_methods.rb | 4 ++ .../forms/dsl/segmented_control_input.rb | 37 ++++++++++++++ .../forms/segmented_control.html.erb | 18 +++++++ .../open_project/forms/segmented_control.rb | 19 +++++++ spec/support/components/common/filters.rb | 14 +++-- 8 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/filter/segmented-control.controller.ts create mode 100644 lib/primer/open_project/forms/dsl/segmented_control_input.rb create mode 100644 lib/primer/open_project/forms/segmented_control.html.erb create mode 100644 lib/primer/open_project/forms/segmented_control.rb diff --git a/app/forms/filters/inputs/boolean_form.rb b/app/forms/filters/inputs/boolean_form.rb index cb71dae2862..c482a0d00b7 100644 --- a/app/forms/filters/inputs/boolean_form.rb +++ b/app/forms/filters/inputs/boolean_form.rb @@ -30,27 +30,19 @@ class Filters::Inputs::BooleanForm < Filters::Inputs::BaseFilterForm def add_operand(group) - group.check_box( + group.segmented_control( + name: "#{@filter.name}_value", label: @filter.human_name, visually_hide_label: true, - name: "v-#{@filter.class.key}", - value: "t", - unchecked_value: "f", - checked: @filter.values.first == "t" + value: @filter.values.first, + items: [ + { value: "f", label: I18n.t("general_text_No") }, + { value: "t", label: I18n.t("general_text_Yes") } + ], + wrapper_data_attributes: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": @filter.name + } ) end - - protected - - def operand_input_id - "v-#{@filter.class.key}" - end - - private - - def filter_row_arguments - super.tap do |args| - args[:data][:"filter--filters-form-target"] = "filter filterValueContainer" - end - end end diff --git a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts index aba9ab1065a..32e0d3ed7a9 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts @@ -503,6 +503,12 @@ export default class FiltersFormController extends Controller { return this.parseDateFilterValue(valueContainer, filterName); } + const hiddenField = valueContainer.querySelector('input[type="hidden"]'); + + if (hiddenField) { + return hiddenField.value ? [hiddenField.value] : null; + } + const value = this.findTargetByName(filterName, this.simpleValueTargets)?.value; if (value && value.length > 0) { diff --git a/frontend/src/stimulus/controllers/dynamic/filter/segmented-control.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/segmented-control.controller.ts new file mode 100644 index 00000000000..8e02a7ead55 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/filter/segmented-control.controller.ts @@ -0,0 +1,51 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write 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 SegmentedControlController extends Controller { + static targets = ['field']; + + declare readonly fieldTarget:HTMLInputElement; + + // Reacts to Primer's itemActivated event (bubbled from segmented-control#select), + // which has already updated aria-current visually. Sets the backing hidden field + // value and bubbles a change event for filter--filters-form auto-submit. + activate(event:CustomEvent<{ item:HTMLButtonElement }>) { + const button = event.detail.item; + const value = button.dataset.value; + + if (value !== undefined) { + this.fieldTarget.value = value; + this.fieldTarget.dispatchEvent(new Event('change', { bubbles: true })); + } + } +} diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index 1ecdb76798b..79650763ba1 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -36,6 +36,10 @@ module Primer add_input AutocompleterInput.new(builder:, form:, **decorate_options(**), &) end + def segmented_control(**, &) + add_input SegmentedControlInput.new(builder:, form:, **decorate_options(**), &) + end + def block_note_editor(**, &) add_input BlockNoteEditorInput.new(builder:, form:, **decorate_options(**), &) end diff --git a/lib/primer/open_project/forms/dsl/segmented_control_input.rb b/lib/primer/open_project/forms/dsl/segmented_control_input.rb new file mode 100644 index 00000000000..73b32082cb3 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/segmented_control_input.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + module Dsl + class SegmentedControlInput < Primer::Forms::Dsl::Input + Item = Data.define(:value, :label) + + attr_reader :name, :label, :current_value, :items, :wrapper_data_attributes + + def initialize(name:, label:, value:, items:, wrapper_data_attributes: {}, **system_arguments) + @name = name + @label = label + @items = items.map { |item| Item.new(value: item[:value], label: item[:label]) } + @current_value = value.presence || @items.first&.value + @wrapper_data_attributes = wrapper_data_attributes + + super(**system_arguments) + end + + def to_component + SegmentedControl.new(input: self) + end + + def type + :segmented_control + end + + def focusable? + true + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/segmented_control.html.erb b/lib/primer/open_project/forms/segmented_control.html.erb new file mode 100644 index 00000000000..0d7ec322270 --- /dev/null +++ b/lib/primer/open_project/forms/segmented_control.html.erb @@ -0,0 +1,18 @@ +<%= render(FormControl.new(input: @input, data: @input.wrapper_data_attributes)) do %> +
+ <%= content_tag(:input, nil, + type: "hidden", + id: builder.field_id(@input.name), + name: builder.field_name(@input.name), + value: @input.current_value, + data: { "filter--segmented-control-target": "field" }) %> + <%= render(Primer::Alpha::SegmentedControl.new("aria-label": @input.label)) do |control| %> + <% @input.items.each do |item| %> + <% control.with_item(label: item.label, + selected: item.value == @input.current_value, + data: { value: item.value }) %> + <% end %> + <% end %> +
+<% end %> diff --git a/lib/primer/open_project/forms/segmented_control.rb b/lib/primer/open_project/forms/segmented_control.rb new file mode 100644 index 00000000000..3f01700e554 --- /dev/null +++ b/lib/primer/open_project/forms/segmented_control.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + # :nodoc: + class SegmentedControl < Primer::Forms::BaseComponent + prepend WrappedInput + + delegate :builder, :form, to: :@input + + def initialize(input:) + super() + @input = input + end + end + end + end +end diff --git a/spec/support/components/common/filters.rb b/spec/support/components/common/filters.rb index 7e671f1636e..2d550684b0b 100644 --- a/spec/support/components/common/filters.rb +++ b/spec/support/components/common/filters.rb @@ -134,16 +134,14 @@ module Components def set_toggle_filter(values) should_active = values.first == "yes" - checkbox = page.find('input[type="checkbox"]') - is_active = checkbox.checked? + label = should_active ? I18n.t("general_text_Yes") : I18n.t("general_text_No") + hidden = page.find('input[type="hidden"]', visible: :all) + is_active = hidden.value == "t" - checkbox.click if should_active != is_active + click_button(label, exact: true) unless should_active == is_active - if should_active - expect(page).to have_field(type: :checkbox, checked: true) - else - expect(page).to have_field(type: :checkbox, checked: false) - end + expected_value = should_active ? "t" : "f" + expect(page).to have_field(hidden["name"], with: expected_value, type: :hidden) end def set_name_and_identifier_filter(values, send_keys: false)