+ )
+ %>
+ <%= 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)