diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb index 3c098cecc75f..92a4abd260ec 100644 --- a/app/components/filter/filter_component.rb +++ b/app/components/filter/filter_component.rb @@ -38,7 +38,7 @@ class FilterComponent < ApplicationComponent options initially_expanded: false def filter_form(form) - Filters::FilterForm.new(form, query:, allowed_filters:) + Filters::FilterFormComponent.new(builder: form, query:, allowed_filters:) end def allowed_filters diff --git a/app/components/filters/filter_form_component.html.erb b/app/components/filters/filter_form_component.html.erb new file mode 100644 index 000000000000..10fc81083664 --- /dev/null +++ b/app/components/filters/filter_form_component.html.erb @@ -0,0 +1,11 @@ +<%= + render( + Primer::ConditionalWrapper.new( + condition: @wrap_with_controller, + **@wrapper_arguments + ) + ) do +%> + <%= hidden_filters_input if @hidden_input_name %> + <%= render(form_list) %> +<% end %> diff --git a/app/forms/filters/filter_form.rb b/app/components/filters/filter_form_component.rb similarity index 63% rename from app/forms/filters/filter_form.rb rename to app/components/filters/filter_form_component.rb index ba1f9b4d3957..18754ee91e05 100644 --- a/app/forms/filters/filter_form.rb +++ b/app/components/filters/filter_form_component.rb @@ -31,29 +31,28 @@ # Renders the list of filter input fields (one row per available filter plus an # "add filter" select) for a given query as part of a Primer form. # -# Unlike most primer forms, this form does not declare a static set of inputs -# via the `form do |f| ... end` DSL. The set of inputs depends on the query's -# available and active filters and is built dynamically at render time. The -# form re-uses the builder of the surrounding `primer_form_with` so that the -# emitted field names match what the controller expects (top-level -# `operator_` and `_value` fields). +# The set of inputs depends on the query's available and active filters and is +# built dynamically at render time. The component receives the builder of the +# surrounding `primer_form_with` so that the emitted field names match what the +# controller expects (top-level `operator_` and `_value` fields). # -# Embed it in any primer form like a normal sub-form: +# Embed it in any primer form: # # <%= primer_form_with(url: ...) do |f| %> # <%= f.text_field(name: :title) %> -# <%= render(Filters::FilterForm.new(f, query: @query)) %> +# <%= render(Filters::FilterFormComponent.new(builder: f, query: @query)) %> # <% end %> # # Customise the set of advertised filters by passing `allowed_filters:` (used # by `Filter::FilterComponent` subclasses that restrict or reorder the list). # -# By default the form does *not* attach the `filter--filters-form` Stimulus +# By default the component does *not* attach the `filter--filters-form` Stimulus # controller, because in the standard layout (e.g. `Projects::IndexSubHeaderComponent`) # the controller has to sit on a common ancestor of the advanced filter form # *and* the inline quick filter input so that `sendForm()` can collect values # from both. For standalone embeds with no co-located quick filter, pass -# `wrap_with_controller: true` and the form will emit its own controller wrapper. +# `wrap_with_controller: true` and the component will emit its own controller +# wrapper. # # Pass `hidden_input_name:` (e.g. `"filters"`) to also emit a hidden input # bound to the Stimulus controller's `filtersInput` target. The controller @@ -65,50 +64,51 @@ # hidden field (and into the URL when `sendForm` redirects). Supported values: # * `:params` (default) — URL-style string: `name ~ "foo"&login ! "bar"`. # * `:json` — JSON array: `[{"name":{"operator":"~","values":["foo"]}}, ...]`. -# Only meaningful when this form owns the controller (`wrap_with_controller: true`); -# otherwise the host's controller wrapper decides. +# Only meaningful when this component owns the controller +# (`wrap_with_controller: true`); otherwise the host's controller wrapper +# decides. # # `autocomplete_append_to:` forwards an `appendTo` selector (or DOM reference # string ng-select understands, e.g. `"#my-dialog"` or `"body"`) to every -# autocompleter the form renders. Use this when the form is embedded in a -# Primer dialog or another container that clips overflow, so the dropdown +# autocompleter the component renders. Use this when the component is embedded +# in a Primer dialog or another container that clips overflow, so the dropdown # portal renders outside that container instead of being clipped. -class Filters::FilterForm < ApplicationForm +class Filters::FilterFormComponent < ApplicationComponent + include OpPrimer::AttributesHelper + include Primer::FetchOrFallbackHelper + OUTPUT_FORMATS = %i[params json].freeze - def initialize(query:, + def initialize(builder:, + query:, allowed_filters: nil, wrap_with_controller: false, hidden_input_name: nil, output_format: nil, - autocomplete_append_to: nil) + autocomplete_append_to: nil, + **wrapper_arguments) super() + @builder = builder @query = query @allowed_filters = allowed_filters || query.available_advanced_filters @wrap_with_controller = wrap_with_controller @hidden_input_name = hidden_input_name - @output_format = validate_output_format(output_format) + @output_format = fetch_or_fallback(OUTPUT_FORMATS, output_format.to_sym) if output_format @autocomplete_append_to = autocomplete_append_to - end - - # Skip the autofocus traversal `Primer::Forms::Base#before_render` performs: - # it walks `inputs`, which requires a static `form do |f| ... end` block. - # The sub-forms rendered via `FormList` run their own `before_render`. - def before_render; end - - def perform_render(&) - list = @view_context.render(Primer::Forms::FormList.new(*sub_forms)) - content = @hidden_input_name ? @view_context.safe_join([hidden_filters_input, list]) : list - return content unless @wrap_with_controller - - # `op-filters-form -expanded` carries the layout styles for the filter - # rows (label on its own line above operator/value) and makes the form - # visible (`op-filters-form` alone is `display: none`). - @view_context.content_tag( - :div, - content, - class: "op-filters-form -expanded", - data: controller_data_attributes + @wrapper_arguments = wrapper_arguments + @wrapper_arguments[:tag] ||= :div + @wrapper_arguments[:classes] = class_names( + "op-filters-form -expanded", + @wrapper_arguments[:classes] + ) + @wrapper_arguments[:data] = merge_data( + @wrapper_arguments, + { + data: { + controller: "filter--filters-form", + filter__filters_form_output_format_value: @output_format&.to_s + } + } ) end @@ -116,28 +116,15 @@ def perform_render(&) attr_reader :query, :allowed_filters - def controller_data_attributes - attrs = { controller: "filter--filters-form" } - attrs["filter--filters-form-output-format-value"] = @output_format.to_s if @output_format - attrs - end - - def validate_output_format(format) - return nil if format.nil? - - sym = format.to_sym - unless OUTPUT_FORMATS.include?(sym) - raise ArgumentError, - "Unknown output_format #{format.inspect}; expected one of #{OUTPUT_FORMATS.inspect}" - end - sym + def form_list + Primer::Forms::FormList.new(*sub_forms) end def hidden_filters_input - @view_context.hidden_field_tag( + hidden_field_tag( @hidden_input_name, "", - data: { "filter--filters-form-target": "filtersInput" } + data: { filter__filters_form_target: "filtersInput" } ) end @@ -154,8 +141,6 @@ def sub_forms ) 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 map_filter allowed_filters.map do |allowed_filter| active_filter = query.find_active_filter(allowed_filter.name) diff --git a/app/components/op_primer/attributes_helper.rb b/app/components/op_primer/attributes_helper.rb new file mode 100644 index 000000000000..4cc905747b0e --- /dev/null +++ b/app/components/op_primer/attributes_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpPrimer + # Drop-in replacement for `Primer::AttributesHelper` that treats the Stimulus + # `controller` data attribute as plural. Stimulus supports multiple + # controllers on one element (space-separated `data-controller="a b"`), but + # upstream Primer omits `controller` from its plural data attributes, so + # `merge_data` would silently drop a caller's controller when a component + # merges in its own. Treating it as plural concatenates them instead. + module AttributesHelper + include Primer::AttributesHelper + + PLURAL_DATA_ATTRIBUTES = (Primer::AttributesHelper::PLURAL_DATA_ATTRIBUTES + %i[controller]).freeze + + def merge_data(*hashes) + merge_prefixed_attribute_hashes(*hashes, prefix: :data, plural_keys: PLURAL_DATA_ATTRIBUTES) + end + end +end diff --git a/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb b/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb index d2cef34cb119..9e75a32abb20 100644 --- a/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb +++ b/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb @@ -37,7 +37,7 @@ def allowed_values raise NotImplementedError, "There would be too many candidates" end - # Tell `Filters::FilterForm`'s dispatch to render these filters with a + # Tell `Filters::FilterFormComponent`'s dispatch to render these filters with a # server-side autocompleter (the candidate set is too large for an inline # `