Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/filter/filter_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/components/filters/filter_form_component.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
Original file line number Diff line number Diff line change
Expand Up @@ -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_<filter>` and `<filter>_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_<filter>` and `<filter>_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
Expand All @@ -65,79 +64,67 @@
# 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

private

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

Expand All @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions app/components/op_primer/attributes_helper.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +38 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also think about moving that to the PVC repo directly...

end
end
Original file line number Diff line number Diff line change
Expand Up @@ -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
# `<select>`). Mirrors what the legacy Angular WP filter UI does — see
# `filter-searchable-multiselect-value.component.html`, which renders an
Expand Down
4 changes: 2 additions & 2 deletions app/models/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def remove_filter(name)
end

# Mirrors `Queries::BaseQuery#find_active_filter` so that consumers built
# on top of the modern query API (e.g. `Filters::FilterForm`) can ask any
# on top of the modern query API (e.g. `Filters::FilterFormComponent`) can ask any
# query — including this legacy work-package one — for its active filter
# by name. Signature kept identical to BaseQuery's (symbol arg in, filter
# or nil out).
Expand All @@ -221,7 +221,7 @@ def find_active_filter(name)
# The manual-sort filter is added programmatically when the user drags
# work packages to reorder them — it has no operator/value UI of its own
# (type `:empty_value`), so it doesn't belong in the picker that
# `Filters::FilterForm` builds. Mirrors how
# `Filters::FilterFormComponent` builds. Mirrors how
# `Queries::Filters::AvailableFilters#available_advanced_filters` already
# excludes the inline `name_and_identifier` quick-filter on projects.
def available_advanced_filters
Expand Down
Loading
Loading