From 1ce489a2e035d9dd1139b6012d03158ebe176ddf Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 5 May 2026 08:28:36 +0200 Subject: [PATCH] Turn partials into form components. wp/74380 --- .../filter/filter_component.html.erb | 141 +++--------------- app/components/filter/filter_component.rb | 57 ++++--- app/components/filter/filters_component.sass | 16 -- app/forms/filters/inputs/add_filter_form.rb | 58 +++++++ app/forms/filters/inputs/autocomplete_form.rb | 52 +++++++ app/forms/filters/inputs/base_filter_form.rb | 110 ++++++++++++++ app/forms/filters/inputs/boolean_form.rb | 49 ++++++ app/forms/filters/inputs/date_form.rb | 114 ++++++++++++++ app/forms/filters/inputs/list_form.rb | 69 +++++++++ .../select_with_toggle_component.html.erb | 12 ++ .../inputs/select_with_toggle_component.rb | 40 +++++ .../inputs/select_with_toggle_input.rb | 52 +++++++ app/forms/filters/inputs/text_form.rb | 50 +++++++ config/initializers/primer_forms.rb | 1 + .../basic-range-date-picker.component.html | 2 + .../content/_advanced_filters.sass | 6 + .../src/global_styles/openproject/_forms.sass | 3 + .../src/global_styles/primer/_overrides.sass | 3 +- .../dynamic/filter/filters-form.controller.ts | 119 +++++++++------ .../open_project/forms/date_picker.html.erb | 4 + .../open_project/forms/dsl/input_methods.rb | 4 + spec/features/projects/lists/columns_spec.rb | 4 +- spec/features/projects/lists/filters_spec.rb | 6 +- spec/support/components/common/filters.rb | 19 ++- spec/support/pages/projects/index.rb | 12 +- 25 files changed, 778 insertions(+), 225 deletions(-) create mode 100644 app/forms/filters/inputs/add_filter_form.rb create mode 100644 app/forms/filters/inputs/autocomplete_form.rb create mode 100644 app/forms/filters/inputs/base_filter_form.rb create mode 100644 app/forms/filters/inputs/boolean_form.rb create mode 100644 app/forms/filters/inputs/date_form.rb create mode 100644 app/forms/filters/inputs/list_form.rb create mode 100644 app/forms/filters/inputs/select_with_toggle_component.html.erb create mode 100644 app/forms/filters/inputs/select_with_toggle_component.rb create mode 100644 app/forms/filters/inputs/select_with_toggle_input.rb create mode 100644 app/forms/filters/inputs/text_form.rb diff --git a/app/components/filter/filter_component.html.erb b/app/components/filter/filter_component.html.erb index 21a8d9e0475..f3e06530bc2 100644 --- a/app/components/filter/filter_component.html.erb +++ b/app/components/filter/filter_component.html.erb @@ -26,134 +26,29 @@ <% else %> <%= helpers.turbo_frame_tag TURBO_FRAME_ID do %> - <%= form_tag( - {}, + <%= primer_form_with( + url: {}, method: :get, data: { action: "submit->filter--filters-form#sendForm:prevent" } - ) do %> -
- <%= - render( - Primer::Beta::IconButton.new( - icon: :x, - scheme: :invisible, - classes: "advanced-filters--close", - tooltip_direction: :se, - aria: { label: t("js.close_form_title") }, - data: { action: "filter--filters-form#toggleDisplayFilters" } - ) + ) do |f| %> + <%= + render( + Primer::Beta::IconButton.new( + icon: :x, + scheme: :invisible, + classes: "advanced-filters--close", + tooltip_direction: :se, + aria: { label: t("js.close_form_title") }, + data: { action: "filter--filters-form#toggleDisplayFilters" } ) - %> - <%= t(:label_filter_plural) %> - -
+ ) + %> + <%= render(Primer::Forms::FormList.new(*inputs(f))) %> + <% unless turbo_requests? %> + <%= render(Primer::Beta::Button.new(type: :submit, scheme: :primary)) { t("button_apply") } %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb index 9654b3b706c..61f534c724b 100644 --- a/app/components/filter/filter_component.rb +++ b/app/components/filter/filter_component.rb @@ -29,7 +29,6 @@ # ++ module Filter class FilterComponent < ApplicationComponent - OPERATORS_WITHOUT_VALUES = %w[* !* t w].freeze TURBO_FRAME_ID = "filter_component" options :query @@ -38,10 +37,22 @@ module Filter options lazy_loaded_path: false options initially_expanded: false - # Returns filters, active and inactive. + def inputs(form) + filter_forms = map_filter do |filter, active, additional_attributes| + filter_form_class(filter, additional_attributes).new(form, filter:, additional_attributes:, active:) + end + + filter_forms << Filters::Inputs::AddFilterForm.new( + form, + allowed_filters:, + active_filter_names: query.filters.map(&:name) + ) + end + + # Maps over all filters (active and inactive). # In case a filter is active, the active one will be preferred over the inactive one. - def each_filter - allowed_filters.each do |allowed_filter| + def map_filter + allowed_filters.map do |allowed_filter| active_filter = query.find_active_filter(allowed_filter.name) filter = active_filter || allowed_filter @@ -53,14 +64,6 @@ module Filter query.available_advanced_filters end - def value_hidden_class(selected_operator) - operator_without_value?(selected_operator) ? "hidden" : "" - end - - def operator_without_value?(operator) - OPERATORS_WITHOUT_VALUES.include?(operator) - end - def lazy_loaded? = !!lazy_loaded_path def initially_expanded? = initially_expanded @@ -90,7 +93,7 @@ module Filter # When the method is overwritten in a subclass, the subclass should call super(filter) to get the default attributes. # # @param filter [QueryFilter] the filter for which we want to pass additional attributes - # @return [Hash] the additional attributes for the filter, that will be yielded in the each_filter method + # @return [Hash] the additional attributes for the filter, yielded in map_filter def additional_filter_attributes(filter) case filter when Queries::Filters::Shared::ProjectFilter::Required, @@ -110,18 +113,36 @@ module Filter end end + def filter_form_class(filter, additional_attributes) + if filter.is_a?(Queries::Filters::Shared::BooleanFilter) + Filters::Inputs::BooleanForm + elsif additional_attributes.key?(:autocomplete_options) + Filters::Inputs::AutocompleteForm + elsif filter.type.in? %i[list list_optional list_all] + Filters::Inputs::ListForm + elsif filter.type.in? %i[datetime_past date] + Filters::Inputs::DateForm + else + Filters::Inputs::TextForm + end + end + def custom_field_list_autocomplete_options(filter) - all_items = if filter.custom_field.version? - filter.allowed_values.map { |name, id, project_name| { name:, id:, project_name: } } - else - filter.allowed_values.map { |name, id| { name:, id: } } - end + all_items = custom_field_allowed_items(filter) selected = filter.values options = { items: all_items } options[:groupBy] = "project_name" if filter.custom_field.version? autocomplete_options.merge(options).merge(model: all_items.select { |item| selected.include?(item[:id]) }) end + def custom_field_allowed_items(filter) + if filter.custom_field.version? + filter.allowed_values.map { |name, id, project_name| { name:, id:, project_name: } } + else + filter.allowed_values.map { |name, id| { name:, id: } } + end + end + def custom_field_hierarchy_autocomplete_options(filter) items = filter.allowed_values.map do |name, id| path = name.split(" / ") diff --git a/app/components/filter/filters_component.sass b/app/components/filter/filters_component.sass index 6e7856e6301..a424079927e 100644 --- a/app/components/filter/filters_component.sass +++ b/app/components/filter/filters_component.sass @@ -4,22 +4,6 @@ &.-expanded display: block .advanced-filters--filter-value - // visibility based on operator type - &.hidden - visibility: hidden - height: 55px - - // visibility for list value selectors - .multi-select - display: none - .single-select - display: block - &.multi-value - .multi-select - display: block - .single-select - display: none - // visibility for datetime_past value selectors &.between-dates >.on-date, diff --git a/app/forms/filters/inputs/add_filter_form.rb b/app/forms/filters/inputs/add_filter_form.rb new file mode 100644 index 00000000000..6f35b761ee8 --- /dev/null +++ b/app/forms/filters/inputs/add_filter_form.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::AddFilterForm < ApplicationForm + def initialize(allowed_filters:, active_filter_names:) + super() + @allowed_filters = allowed_filters + @active_filter_names = active_filter_names + end + + form do |form| + form.select_list( + name: :add_filter_select, + label: I18n.t(:label_filter_add), + scope_name_to_model: false, + prompt: I18n.t(:actionview_instancetag_blank_option), + data: { + "filter--filters-form-target": "addFilterSelect", + action: "change->filter--filters-form#addFilter:prevent" + } + ) do |select| + @allowed_filters.each do |filter| + select.option( + label: filter.human_name, + value: filter.name, + disabled: @active_filter_names.include?(filter.name) + ) + end + end + end +end diff --git a/app/forms/filters/inputs/autocomplete_form.rb b/app/forms/filters/inputs/autocomplete_form.rb new file mode 100644 index 00000000000..e1daa0791b2 --- /dev/null +++ b/app/forms/filters/inputs/autocomplete_form.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::AutocompleteForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + group.autocompleter( + name: "v-#{@filter.class.key}", + label: @filter.human_name, + visually_hide_label: true, + required: true, + include_blank: false, + wrapper_data_attributes: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": @filter.name, + "filter-autocomplete": "true" + }, + autocomplete_options: @additional_attributes[:autocomplete_options].merge( + multiple: true, + multipleAsSeparateInputs: false, + inputName: "value", + inputValue: @filter.values + ) + ) + end +end diff --git a/app/forms/filters/inputs/base_filter_form.rb b/app/forms/filters/inputs/base_filter_form.rb new file mode 100644 index 00000000000..48f7f0bcc48 --- /dev/null +++ b/app/forms/filters/inputs/base_filter_form.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. +#++ + +class Filters::Inputs::BaseFilterForm < ApplicationForm + def initialize(filter:, additional_attributes:, active:) + super() + @filter = filter + @additional_attributes = additional_attributes + @active = active + end + + def self.inherited(subclass) + super + subclass.form do |form| + form.group(**filter_row_arguments) do |group| + add_operator(group) + add_operand(group) + add_delete_button(group) + end + end + end + + protected + + def add_operand(_group) + raise SubclassResponsibilityError + end + + def filter_row_arguments + args = { + layout: :horizontal, + classes: "advanced-filters--filter", + data: { + "filter--filters-form-target": "filter", + "filter-name": @filter.name, + "filter-type": @filter.type + } + } + args[:hidden] = "hidden" unless @active + args + end + + private + + def add_operator(group) + selected_operator = @filter.operator || @filter.default_operator.symbol + + group.select_list( + name: :"operator_#{@filter.name}", + label: @filter.human_name, + scope_name_to_model: false, + hidden: @filter.is_a?(Queries::Filters::Shared::BooleanFilter), + data: { + action: "change->filter--filters-form#setValueVisibility", + "filter--filters-form-filter-name-param": @filter.name, + "filter--filters-form-target": "operator", + "filter-name": @filter.name + } + ) do |select| + @filter.available_operators.each do |op| + select.option( + label: op.human_name, + value: op.symbol, + selected: op.symbol == selected_operator + ) + end + end + end + + def add_delete_button(group) + group.button( + name: :remove_filter, + label: I18n.t("button_delete"), + scheme: :danger, + data: { + action: "click->filter--filters-form#removeFilter", + "filter--filters-form-filter-name-param": @filter.name + } + ) do |button| + button.with_leading_visual_icon(icon: :trash) + end + end +end diff --git a/app/forms/filters/inputs/boolean_form.rb b/app/forms/filters/inputs/boolean_form.rb new file mode 100644 index 00000000000..5e5e2e20e5a --- /dev/null +++ b/app/forms/filters/inputs/boolean_form.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::BooleanForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + group.check_box( + label: @filter.human_name, + name: "v-#{@filter.class.key}", + value: "t", + unchecked_value: "f", + checked: @filter.values.first == "t" + ) + end + + private + + def filter_row_arguments + super.tap do |args| + args[:data][:"filter--filters-form-target"] = "filter filterValueContainer" + end + end +end diff --git a/app/forms/filters/inputs/date_form.rb b/app/forms/filters/inputs/date_form.rb new file mode 100644 index 00000000000..cb156cf5a36 --- /dev/null +++ b/app/forms/filters/inputs/date_form.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::DateForm < Filters::Inputs::BaseFilterForm + DAYS_OPERATORS = %w[>t- t+ t+].freeze + + def add_operand(group) + filter_name = @filter.name + filter_values = @filter.values + fmt = date_format + + days_value = fmt == "days" ? filter_values.fetch(0, "") : nil + on_date_value = fmt == "on-date" ? filter_values.fetch(0, "") : nil + from_value = fmt == "between-dates" ? filter_values.fetch(0, "") : nil + to_value = fmt == "between-dates" ? filter_values.fetch(1, "") : nil + + group.multi(name: filter_name, label: filter_name, visually_hide_label: true, + class: ["advanced-filters--filter-value"], + data: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": filter_name + }) do |builder| + days_div(builder, filter_name, days_value) + on_date_div(builder, filter_name, on_date_value) + between_dates_div(builder, filter_name, from_value, to_value) + end + end + + private + + def date_format + op = @filter.operator || @filter.default_operator.symbol + @date_format ||= if DAYS_OPERATORS.include?(op) + "days" + elsif op == "=d" + "on-date" + elsif op == "<>d" + "between-dates" + end + end + + def days_div(builder, filter_name, value) + field_arguments = { + id: "#{filter_name}_value", + name: :days, + label: I18n.t("datetime.units.day.other"), + visually_hide_label: true, + trailing_visual: { text: { text: I18n.t("datetime.units.day.other") } }, + scope_name_to_model: false, + value:, + hidden: value.nil?, + data: { + "filter--filters-form-target": "days", + "filter-name": filter_name + } + } + + builder.text_field(**field_arguments, type: :number, step: "any", class: "days") + end + + def on_date_div(builder, filter_name, value) + builder.single_date_picker( + name: :singleDay, + label: :singleDay, + hidden: value.nil?, + leading_visual: { icon: :calendar }, + value:, + data: { + "filter-name": filter_name + } + ) + end + + def between_dates_div(builder, filter_name, from_value, to_value) + value = [from_value, to_value].compact.join(" - ").presence + builder.range_date_picker( + name: :dateRange, + label: :dateRange, + hidden: value.nil?, + leading_visual: { icon: :calendar }, + value:, + data: { + "filter-name": filter_name + } + ) + end +end diff --git a/app/forms/filters/inputs/list_form.rb b/app/forms/filters/inputs/list_form.rb new file mode 100644 index 00000000000..ac2f2b9e158 --- /dev/null +++ b/app/forms/filters/inputs/list_form.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::ListForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + filter_name = @filter.name + filter_values = @filter.values || [] + allowed_values = @filter.allowed_values + multi_value = filter_values.size > 1 + + group.multi(name: :value, label: :value, visually_hide_label: true, + class: ["advanced-filters--filter-value"], + data: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": filter_name, + "multi-value": multi_value.to_s + }) do |builder| + builder.select_with_toggle( + name: :single, label: :single, + allowed_values:, + selected_values: [filter_values.first].compact, + multiple: false, collapse: false, filter_name:, + hidden: multi_value, + data: { + "filter--filters-form-target": "filterValueSelect", + "filter-name": filter_name + } + ) + builder.select_with_toggle( + name: :multi, label: :multi, + allowed_values:, + selected_values: filter_values, + multiple: true, collapse: true, filter_name:, + hidden: !multi_value, + data: { + "filter--filters-form-target": "filterValueSelect", + "filter-name": filter_name + } + ) + end + end +end diff --git a/app/forms/filters/inputs/select_with_toggle_component.html.erb b/app/forms/filters/inputs/select_with_toggle_component.html.erb new file mode 100644 index 00000000000..dfb3df04a80 --- /dev/null +++ b/app/forms/filters/inputs/select_with_toggle_component.html.erb @@ -0,0 +1,12 @@ +<%= render(FormControl.new(input: @input)) do %> + <%= content_tag(:div, **@field_wrap_arguments) do %> + <%= builder.select(@input.name, options, + { selected: @input.selected_values }.merge(@input.select_arguments), + **@input.input_arguments) %> + <%= render(Primer::Beta::Button.new(scheme: :invisible, type: :button, + aria: { label: I18n.t(:label_enable_multi_select) }, + data: button_data)) do |btn| %> + <% btn.with_leading_visual_icon(icon: @input.collapse? ? :dash : :plus) %> + <% end %> + <% end %> +<% end %> diff --git a/app/forms/filters/inputs/select_with_toggle_component.rb b/app/forms/filters/inputs/select_with_toggle_component.rb new file mode 100644 index 00000000000..6f35a2aa9e1 --- /dev/null +++ b/app/forms/filters/inputs/select_with_toggle_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::SelectWithToggleComponent < Primer::Forms::Select + def button_data + { + action: "click->filter--filters-form#toggleMultiSelect", + "filter--filters-form-filter-name-param": @input.filter_name, + "filter-name": @input.filter_name, + collapse: @input.collapse?.to_s + } + end +end diff --git a/app/forms/filters/inputs/select_with_toggle_input.rb b/app/forms/filters/inputs/select_with_toggle_input.rb new file mode 100644 index 00000000000..852336e28fa --- /dev/null +++ b/app/forms/filters/inputs/select_with_toggle_input.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::SelectWithToggleInput < Primer::Forms::Dsl::SelectInput + attr_reader :selected_values, :filter_name + + def initialize(name:, label:, allowed_values:, selected_values:, collapse:, filter_name:, **system_arguments) + @selected_values = Array(selected_values) + @collapse = collapse + @filter_name = filter_name + super(name:, label:, **system_arguments) do |select| + allowed_values.each { |opt_label, opt_value| select.option(label: opt_label, value: opt_value) } + end + end + + def collapse? = @collapse + + def to_component + Filters::Inputs::SelectWithToggleComponent.new(input: self) + end + + def type + :select_with_toggle + end +end diff --git a/app/forms/filters/inputs/text_form.rb b/app/forms/filters/inputs/text_form.rb new file mode 100644 index 00000000000..ba2efbc2072 --- /dev/null +++ b/app/forms/filters/inputs/text_form.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::TextForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + field_arguments = { + name: :"v-#{@filter.class.key}", + label: @filter.human_name, + visually_hide_label: true, + scope_name_to_model: false, + value: @filter.values.first, + data: { + "filter--filters-form-target": "filterValueContainer simpleValue", + "filter-name": @filter.name + } + } + if @filter.type.in? %i[integer float] + group.text_field(**field_arguments, type: :number, step: "any") + else + group.text_field(**field_arguments) + end + end +end diff --git a/config/initializers/primer_forms.rb b/config/initializers/primer_forms.rb index 9aad976cd49..b3dec93ff21 100644 --- a/config/initializers/primer_forms.rb +++ b/config/initializers/primer_forms.rb @@ -31,4 +31,5 @@ Rails.application.config.to_prepare do Primer::Forms::Dsl::FormObject.include(Primer::OpenProject::Forms::Dsl::InputMethods) Primer::Forms::Dsl::InputGroup.include(Primer::OpenProject::Forms::Dsl::InputMethods) + Primer::Forms::Dsl::MultiInput.include(Primer::OpenProject::Forms::Dsl::InputMethods) end diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html index 8c320747278..5803a08cd1d 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html @@ -4,6 +4,7 @@ type="text" autocomplete="off" class="spot-input op-basic-range-datepicker--input" + data-filter--filters-form-target="dateRange" data-test-selector="op-basic-range-date-picker" [ngClass]="inputClassNames" [attr.data-value]="value" @@ -50,6 +51,7 @@ /> option background-color: var(--body-background) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index f9f3869f6c0..3215f428457 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -38,7 +38,8 @@ &-input-wrap[invalid='true'] input:not(:focus) border-color: var(--control-borderColor-danger) - &-spacingWrapper > :empty + &-spacingWrapper > :empty, + &-spacingWrapper:not(:has(> :not([hidden]))) display: none .UnderlineNav 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 7250e3cbcdc..3384243b358 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts @@ -36,6 +36,7 @@ import { hideElement, showElement, } from 'core-app/shared/helpers/dom-helpers'; +import { PrimerMultiInputElement } from '@primer/view-components/app/lib/primer/forms/primer_multi_input'; interface PrimerTextFieldElement extends HTMLElement { inputElement:HTMLInputElement; @@ -56,12 +57,12 @@ export default class FiltersFormController extends Controller { 'simpleFilter', 'filter', 'addFilterSelect', - 'spacer', 'operator', 'filterValueContainer', 'filterValueSelect', 'days', 'singleDay', + 'dateRange', 'simpleValue', ]; @@ -70,12 +71,12 @@ export default class FiltersFormController extends Controller { declare readonly simpleFilterTargets:HTMLElement[]; declare readonly filterTargets:HTMLElement[]; declare readonly addFilterSelectTarget:HTMLSelectElement; - declare readonly spacerTarget:HTMLElement; declare readonly operatorTargets:HTMLSelectElement[]; declare readonly filterValueContainerTargets:HTMLElement[]; declare readonly filterValueSelectTargets:HTMLSelectElement[]; declare readonly daysTargets:HTMLInputElement[]; declare readonly singleDayTargets:HTMLInputElement[]; + declare readonly dateRangeTargets:HTMLInputElement[]; declare readonly simpleValueTargets:HTMLInputElement[]; declare readonly hasFilterFormToggleTarget:boolean; @@ -140,6 +141,13 @@ export default class FiltersFormController extends Controller { filterValueContainerTargetConnected(target:HTMLElement) { this.addChangeListener(target); + const filterName = target.getAttribute('data-filter-name'); + if (filterName) { + const operator = this.findTargetByName(filterName, this.operatorTargets); + if (operator && this.noValueOperators.includes(operator.value)) { + target.setAttribute('hidden', ''); + } + } } filterValueSelectTargetConnected(target:HTMLElement) { @@ -152,6 +160,31 @@ export default class FiltersFormController extends Controller { singleDayTargetConnected(target:HTMLElement) { this.addChangeListener(target); + this.registerAngularPickerWithMultiInput(target, 'opce-basic-single-date-picker'); + } + + dateRangeTargetConnected(target:HTMLElement) { + this.addChangeListener(target); + this.registerAngularPickerWithMultiInput(target, 'opce-range-date-picker'); + } + + // angular_component_tag serialises @Input() bindings as JSON in data-* attributes, so the + // Angular date picker wrapper's data-name holds a JSON-encoded value that primer-multi-input + // cannot use to identify its child fields. date_picker.html.erb therefore also sets + // data-multi-input-name as a plain string with the intended field name. + // This method is called when the Stimulus target connects, i.e. after Angular has already + // bootstrapped the component and consumed its data-* attributes, so we can safely overwrite + // data-name with the plain string from data-multi-input-name without interfering with Angular. + // primer-multi-input.activateField() then uses data-name to show/hide the correct picker + // when the filter operator changes. + private registerAngularPickerWithMultiInput(target:HTMLElement, tagName:string) { + const wrapper = target.closest(tagName); + if (wrapper?.closest('primer-multi-input')) { + const multiInputName = wrapper.getAttribute('data-multi-input-name'); + if (multiInputName) { + wrapper.setAttribute('data-name', multiInputName); + } + } } simpleValueTargetDisconnected(target:HTMLElement) { @@ -178,6 +211,10 @@ export default class FiltersFormController extends Controller { this.removeChangeListener(target); } + dateRangeTargetDisconnected(target:HTMLElement) { + this.removeChangeListener(target); + } + filterFormTargetConnected() { // Didn't really change, but we need to ensure that the visibility of the target is correct. // This is caused by there first being a skeleton form, which allows the user to already @@ -217,17 +254,22 @@ export default class FiltersFormController extends Controller { toggleMultiSelect({ params: { filterName } }:{ params:{ filterName:string } }) { const valueContainer = this.findTargetByName(filterName, this.filterValueContainerTargets); - const singleSelect = this.findTargetByName(filterName, this.filterValueSelectTargets, (selectField) => !selectField.multiple); - const multiSelect = this.findTargetByName(filterName, this.filterValueSelectTargets, (selectField) => selectField.multiple); - if (valueContainer && singleSelect && multiSelect) { - if (valueContainer.classList.contains('multi-value')) { - const valueToSelect = this.getValueToSelect(multiSelect); - this.setSelectOptions(singleSelect, valueToSelect); + const singleSelect = this.findTargetByName(filterName, this.filterValueSelectTargets, (s) => !s.multiple); + const multiSelect = this.findTargetByName(filterName, this.filterValueSelectTargets, (s) => s.multiple); + const multiInput = valueContainer?.querySelector('primer-multi-input'); + + if (valueContainer && singleSelect && multiSelect && multiInput) { + const isMultiMode = valueContainer.dataset.multiValue === 'true'; + + if (isMultiMode) { + this.setSelectOptions(singleSelect, this.getValueToSelect(multiSelect)); + multiInput.activateField('single'); } else { - const valueToSelect = this.getValueToSelect(singleSelect); - this.setSelectOptions(multiSelect, valueToSelect); + this.setSelectOptions(multiSelect, this.getValueToSelect(singleSelect)); + multiInput.activateField('multi'); } - valueContainer.classList.toggle('multi-value'); + + valueContainer.dataset.multiValue = isMultiMode ? 'false' : 'true'; } } @@ -269,11 +311,10 @@ export default class FiltersFormController extends Controller { addFilterByName(filterName:string) { const selectedFilter = this.findTargetByName(filterName, this.filterTargets); if (selectedFilter) { - selectedFilter.classList.remove('hidden'); + selectedFilter.removeAttribute('hidden'); } this.addFilterSelectTarget.selectedOptions[0].disabled = true; this.addFilterSelectTarget.selectedIndex = 0; - this.setSpacerVisibility(); this.focusFilterValueIfPossible(selectedFilter); @@ -313,12 +354,11 @@ export default class FiltersFormController extends Controller { removeFilter({ params: { filterName } }:{ params:{ filterName:string } }) { const filterToRemove = this.findTargetByName(filterName, this.filterTargets); - filterToRemove?.classList.add('hidden'); + filterToRemove?.setAttribute('hidden', ''); const selectOptions = Array.from(this.addFilterSelectTarget.options); const removedFilterOption = selectOptions.find((option) => option.value === filterName); removedFilterOption?.removeAttribute('disabled'); - this.setSpacerVisibility(); if (this.performTurboRequestsValue) { this.sendForm(); @@ -340,18 +380,6 @@ export default class FiltersFormController extends Controller { inputElement.dispatchEvent(inputEvent); } - private setSpacerVisibility() { - if (this.anyFiltersStillVisible()) { - this.spacerTarget.classList.remove('hidden'); - } else { - this.spacerTarget.classList.add('hidden'); - } - } - - private anyFiltersStillVisible() { - return this.filterTargets.some((filter) => !filter.classList.contains('hidden')); - } - private readonly noValueOperators = ['*', '!*', 't', 'w']; private readonly daysOperators = ['>t-', 't+', 't+']; private readonly onDateOperator = '=d'; @@ -362,23 +390,18 @@ export default class FiltersFormController extends Controller { const valueContainer = this.findTargetByName(filterName, this.filterValueContainerTargets); if (valueContainer) { if (this.noValueOperators.includes(selectedOperator)) { - valueContainer.classList.add('hidden'); + valueContainer.setAttribute('hidden', ''); } else { - valueContainer.classList.remove('hidden'); + valueContainer.removeAttribute('hidden'); } + const multiInput = valueContainer.querySelector('primer-multi-input'); if (this.daysOperators.includes(selectedOperator)) { - valueContainer.classList.add('days'); - valueContainer.classList.remove('on-date'); - valueContainer.classList.remove('between-dates'); + multiInput?.activateField('days'); } else if (selectedOperator === this.onDateOperator) { - valueContainer.classList.add('on-date'); - valueContainer.classList.remove('days'); - valueContainer.classList.remove('between-dates'); + multiInput?.activateField('singleDay'); } else if (selectedOperator === this.betweenDatesOperator) { - valueContainer.classList.add('between-dates'); - valueContainer.classList.remove('days'); - valueContainer.classList.remove('on-date'); + multiInput?.activateField('dateRange'); } } } @@ -455,7 +478,7 @@ export default class FiltersFormController extends Controller { } private parseAdvancedFilters():InternalFilterValue[] { - const advancedFilters = this.filterTargets.filter((filter) => !filter.classList.contains('hidden')); + const advancedFilters = this.filterTargets.filter((filter) => !filter.hasAttribute('hidden')); const filters:InternalFilterValue[] = []; advancedFilters.forEach((filter) => { @@ -533,9 +556,10 @@ export default class FiltersFormController extends Controller { } private parseSelectFilterValue(valueContainer:HTMLElement, filterName:string) { + const isMultiMode = valueContainer.dataset.multiValue === 'true'; let selectFields; - if (valueContainer.classList.contains('multi-value')) { + if (isMultiMode) { selectFields = this.filterValueSelectTargets.filter((selectField) => selectField.multiple && selectField.getAttribute('data-filter-name') === filterName); } else { selectFields = this.filterValueSelectTargets.filter((selectField) => !selectField.multiple && selectField.getAttribute('data-filter-name') === filterName); @@ -550,20 +574,21 @@ export default class FiltersFormController extends Controller { return null; } - private parseDateFilterValue(valueContainer:HTMLElement, filterName:string) { + private parseDateFilterValue(_valueContainer:HTMLElement, filterName:string) { let value; + const operator = this.findTargetByName(filterName, this.operatorTargets)?.value; - if (valueContainer.classList.contains('days')) { + if (operator && this.daysOperators.includes(operator)) { const dateValue = this.findTargetByName(filterName, this.daysTargets)?.value; value = _.without([dateValue], ''); - } else if (valueContainer.classList.contains('on-date')) { - const dateValue = this.findTargetById(`on-date-value-${filterName}`, this.singleDayTargets)?.value; + } else if (operator === this.onDateOperator) { + const dateValue = this.findTargetById(filterName, this.singleDayTargets)?.value; value = _.without([dateValue], ''); - } else if (valueContainer.classList.contains('between-dates')) { - const fromValue = this.findTargetById(`between-dates-from-value-${filterName}`, this.singleDayTargets)?.value; - const toValue = this.findTargetById(`between-dates-to-value-${filterName}`, this.singleDayTargets)?.value; + } else if (operator === this.betweenDatesOperator) { + const rangeValue = this.findTargetById(filterName, this.dateRangeTargets)?.value; + const [fromValue, toValue] = rangeValue?.split(' - ') ?? []; value = [fromValue, toValue]; } diff --git a/lib/primer/open_project/forms/date_picker.html.erb b/lib/primer/open_project/forms/date_picker.html.erb index a9cd1e8f6b0..46cf8dcd796 100644 --- a/lib/primer/open_project/forms/date_picker.html.erb +++ b/lib/primer/open_project/forms/date_picker.html.erb @@ -40,6 +40,10 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <%= render Primer::ConditionalWrapper.new(condition: @input.auto_check_src, tag: "auto-check", csrf: auto_check_authenticity_token, src: @input.auto_check_src) do %> <%= angular_component_tag @datepicker_options.fetch(:component), + data: { + targets: @input.input_arguments.dig(:data, :targets), + "multi-input-name": @input.input_arguments.dig(:data, :name) + }.compact, inputs: @datepicker_options.merge( id: @datepicker_options.fetch(:id) { builder.field_id(@input.name) }, name: @datepicker_options.fetch(:name) { builder.field_name(@input.name) }, diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index 1ecdb76798b..aa2dbb3d8ee 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -11,6 +11,10 @@ module Primer super(**decorate_options(**), &) end + def select_with_toggle(**, &) + add_input Filters::Inputs::SelectWithToggleInput.new(builder:, form:, **decorate_options(**), &) + end + def check_box(**, &) super(**decorate_options(**), &) end diff --git a/spec/features/projects/lists/columns_spec.rb b/spec/features/projects/lists/columns_spec.rb index 048f0efe1ea..4389d24765c 100644 --- a/spec/features/projects/lists/columns_spec.rb +++ b/spec/features/projects/lists/columns_spec.rb @@ -224,7 +224,7 @@ RSpec.describe "Projects lists columns", :js, with_settings: { login_required?: # Filter component is visible expect(page).to have_select("add_filter_select") # Filter for column is visible and can now be specified by the user - expect(page).to have_css(".advanced-filters--filter-name[for='created_at']") + expect(page).to have_css(".advanced-filters--filter[data-filter-name='created_at']") # The correct filter input field has focus expect(page.has_focus_on?(".advanced-filters--filter-value input#created_at_value")).to be(true) @@ -238,7 +238,7 @@ RSpec.describe "Projects lists columns", :js, with_settings: { login_required?: # Filter component is visible expect(page).to have_select("add_filter_select") # Filter for column is visible. Note that the filter name is different from the column attribute! - expect(page).to have_css(".advanced-filters--filter-name[for='project_status_code']") + expect(page).to have_css(".advanced-filters--filter[data-filter-name='project_status_code']") end it "does not offer to filter if the column has no associated filter" do diff --git a/spec/features/projects/lists/filters_spec.rb b/spec/features/projects/lists/filters_spec.rb index 441356c85af..f09208905a9 100644 --- a/spec/features/projects/lists/filters_spec.rb +++ b/spec/features/projects/lists/filters_spec.rb @@ -142,7 +142,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f load_and_open_filters admin # value selection defaults to "active"' - expect(page).to have_css('li[data-filter-name="active"]') + expect(page).to have_css('.advanced-filters--filter[data-filter-name="active"]') projects_page.expect_projects_listed(parent_project, child_project, @@ -601,7 +601,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f projects_page.expect_projects_listed(project) # switching to multiselect keeps the current selection - cf_filter = page.find("li[data-filter-name='#{list_custom_field.column_name}']") + cf_filter = page.find(".advanced-filters--filter[data-filter-name='#{list_custom_field.column_name}']") select_value_id = "#{list_custom_field.column_name}_value" @@ -614,7 +614,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f projects_page.expect_projects_not_listed(development_project) projects_page.expect_projects_listed(project) - cf_filter = page.find("li[data-filter-name='#{list_custom_field.column_name}']") + cf_filter = page.find(".advanced-filters--filter[data-filter-name='#{list_custom_field.column_name}']") within(cf_filter) do # Query has two values for that filter. projects_page.expect_ng_value_label(select_value_id, diff --git a/spec/support/components/common/filters.rb b/spec/support/components/common/filters.rb index 93a946a0cf9..e5f73e23ea0 100644 --- a/spec/support/components/common/filters.rb +++ b/spec/support/components/common/filters.rb @@ -43,12 +43,12 @@ module Components if filter_name == "name_and_identifier" expect(page.find_by_id(filter_name).value).not_to be_empty elsif value - within("li[data-filter-name='#{filter_name}']") do + within(".advanced-filters--filter[data-filter-name='#{filter_name}']") do expect(page).to have_css(".advanced-filters--filter-value", text: value, visible: :all) end else expect(page) - .to have_css("li[data-filter-name='#{filter_name}']") + .to have_css(".advanced-filters--filter[data-filter-name='#{filter_name}']") end end @@ -119,29 +119,28 @@ module Components def select_filter(name, human_name) select human_name, from: "add_filter_select" - page.find("li[data-filter-name='#{name}']") + page.find(".advanced-filters--filter[data-filter-name='#{name}']") end def remove_filter(name) if name == "name_and_identifier" page.find_by_id("name_and_identifier").find(:xpath, "following-sibling::button").click else - page.find("li[data-filter-name='#{name}'] .filter_rem").click + page.find(".advanced-filters--filter[data-filter-name='#{name}'] .advanced-filters--remove-filter").click end end def set_toggle_filter(values) should_active = values.first == "yes" - is_active = page.has_selector? '[data-test-selector="spot-switch-handle"][data-qa-active]' + checkbox = page.find('input[type="checkbox"]') + is_active = checkbox.checked? - if should_active != is_active - page.find('[data-test-selector="spot-switch-handle"]').click - end + checkbox.click if should_active != is_active if should_active - expect(page).to have_css('[data-test-selector="spot-switch-handle"][data-qa-active]') + expect(page).to have_field(type: :checkbox, checked: true) else - expect(page).to have_css('[data-test-selector="spot-switch-handle"]:not([data-qa-active])') + expect(page).to have_field(type: :checkbox, checked: false) end end diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index 727c3094473..653f3f0c4ed 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -238,10 +238,12 @@ module Pages end def set_advanced_filter(name, human_name, human_operator = nil, values = [], send_keys: false) - selected_filter = select_filter(name, human_name) + select_filter(name, human_name) + + # Re-find after select_filter, as adding the filter triggers DOM updates + # that make the returned reference immediately stale (ObsoleteNode) + selected_filter = page.find(".advanced-filters--filter[data-filter-name='#{name}']") - # Detect filter type before apply_operator, which may trigger Turbo stream - # updates that make the selected_filter node reference stale (ObsoleteNode) is_autocomplete = autocomplete_filter?(selected_filter) is_date_or_datetime = date_filter?(selected_filter) || date_time_filter?(selected_filter) @@ -251,8 +253,8 @@ module Pages return unless values.any? - # Re-find element as apply_operator may have triggered DOM updates - selected_filter = page.find("li[data-filter-name='#{name}']") + # Re-find again as apply_operator may have triggered further DOM updates + selected_filter = page.find(".advanced-filters--filter[data-filter-name='#{name}']") within(selected_filter) do if boolean_filter?(name)