Add Lookbook for Filter Forms

This commit is contained in:
Klaus Zanders
2026-05-28 15:44:07 +02:00
parent 1f00123aa4
commit d726484b7b
6 changed files with 249 additions and 0 deletions
@@ -0,0 +1,87 @@
Filters in OpenProject are driven by query objects (subclasses of
`Queries::BaseQuery`). Each query exposes the filters that are available for
its target model and the ones that are currently active. There are two
building blocks for rendering those filters as a UI:
* **`Filter::FilterComponent`** — the full filter panel as it appears on
index pages: a turbo frame, a `BorderBox`, lazy loading, action buttons
(Apply / Close), and the filter rows themselves. Use this when you want
the standard OpenProject filter dropdown experience.
* **`Filters::FilterForm`** — just the filter rows plus the "Add filter"
select, rendered as a primer form object. Use this when you want filters
inside *your own* form (e.g. a configuration dialog) or when the
surrounding chrome of `FilterComponent` doesn't fit.
Internally `FilterComponent` delegates to `FilterForm`, so the two stay in
lockstep — `FilterForm` is the single source of truth for which inputs to
render, and `FilterComponent` adds the wrapping chrome on top.
## Filter::FilterComponent
The component takes a query and renders the complete filter UI. The
surrounding host (typically a sub-header component) is responsible for
attaching the `filter--filters-form` Stimulus controller because it needs
to coordinate with co-located quick filter inputs.
```erb
<%%= render(::Projects::ProjectsFiltersComponent.new(
query: @query,
lazy_loaded_path: :projects_filters_path,
initially_expanded: filters_expanded?
)) %>
```
To restrict or reorder the advertised filters, subclass `Filter::FilterComponent`
and override `allowed_filters`:
```ruby
class MyFiltersComponent <Filter::FilterComponent
def allowed_filters
super
.grep_v
<%= embed OpenProject::Filter::FiltersComponentPreview, :default, panels: %i[source] %>
<%= embed OpenProject::Filter::FilterFormPreview, :default, panels: %i[source] %>
<%= embed OpenProject::Filter::FilterFormPreview, :with_active_filter, panels: %i[source] %>
<%= embed OpenProject::Filter::FilterFormPreview, :with_hidden_input, panels: %i[source] %>
<%= embed OpenProject::Filter::FilterFormPreview, :combined_with_other_inputs, panels: %i[source] %>
<%%= primer_form_with(...) do |f| %>
<%%= render(
Filters::FilterForm.new(
f,
query: @query,
wrap_with_controller: true,
hidden_input_name: "filters",
autocomplete_append_to: "#my-dialog"
)
) %>
<%% end %>
```
## When to use which
| You want… | Use |
|----------------------------------------------------------|---------------------------|
| The standard OpenProject filter panel on an index page | `Filter::FilterComponent` |
| Filters inside a dialog or a non-filter form | `Filters::FilterForm` |
| Coordination with a quick filter input in a sub-header | `Filter::FilterComponent` (the sub-header attaches the controller) |
| The serialized filter string in `params` on submit | `Filters::FilterForm` + `hidden_input_name:` |
| Autocompleter dropdowns to escape a clipping container | `Filters::FilterForm` + `autocomplete_append_to:` |
## Stimulus controller placement
The `filter--filters-form` controller has to sit on a **common ancestor**
of every filter UI that should coordinate. It does two things callers tend
to forget about:
1. `parseFilters()` collects values from both the inline quick filter
(`simpleFilter` targets) and the advanced filter rows (`filter`
targets). If they live under different controller instances, typing in
the quick input won't include active advanced filters in the URL.
2. The clear-button on a quick filter (`clearButtonIdValue`) hooks back
into the same controller.
That's why `Filter::FilterComponent` does *not* attach the controller
itself — the surrounding `IndexSubHeaderComponent` does, so quick filter
and advanced form share one. For standalone embeds without a co-located
quick filter, `FilterForm`'s `wrap_with_controller: true` is the right
default.
@@ -0,0 +1,62 @@
# 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 OpenProject
module Filter
# @logical_path OpenProject/Filter
class FilterFormPreview < Lookbook::Preview
# @display min_height 600px
def default
render_with_template(locals: { query: UserQuery.new })
end
# @display min_height 600px
# @label With an active filter
def with_active_filter
query = UserQuery.new
query.where(:login, "~", ["admin"])
render_with_template(locals: { query: })
end
# @display min_height 600px
# @label Hidden input mode
# @param output_format [Symbol] select [json, params]
def with_hidden_input(output_format: :json)
render_with_template(locals: { query: UserQuery.new, output_format: output_format.to_sym })
end
# @display min_height 600px
# @label Combined with other inputs
def combined_with_other_inputs
render_with_template(locals: { query: UserQuery.new })
end
end
end
end
@@ -0,0 +1,25 @@
<%# `Filters::FilterForm` is a regular primer form object — combine it with %>
<%# other forms in a single `Primer::Forms::FormList` to share one builder %>
<%# (and therefore one submission) with non-filter inputs. %>
<%
note_form = Class.new(ApplicationForm) do
form do |f|
f.text_field(name: :note, label: "Note", scope_name_to_model: false)
end
end
%>
<%= primer_form_with(url: "/foo", method: :post) do |f| %>
<%=
render(
Primer::Forms::FormList.new(
note_form.new(f),
Filters::FilterForm.new(
f,
query: query,
wrap_with_controller: true,
hidden_input_name: "filters"
)
)
)
%>
<% end %>
@@ -0,0 +1,13 @@
<%# Standalone usage: the form owns the Stimulus controller wrapper so it %>
<%# works without any surrounding scaffolding. %>
<%= primer_form_with(url: "/foo", method: :post) do |f| %>
<%=
render(
Filters::FilterForm.new(
f,
query: query,
wrap_with_controller: true
)
)
%>
<% end %>
@@ -0,0 +1,14 @@
<%# An active filter on the query renders its row visible (no `hidden` %>
<%# attribute) and pre-fills operator/values; inactive filters still %>
<%# render but are hidden until added from the "Add filter" select. %>
<%= primer_form_with(url: "/foo", method: :post) do |f| %>
<%=
render(
Filters::FilterForm.new(
f,
query: query,
wrap_with_controller: true
)
)
%>
<% end %>
@@ -0,0 +1,48 @@
<%# `hidden_input_name:` emits a hidden field that the Stimulus controller %>
<%# keeps in sync with the serialized filter selections. A normal form %>
<%# submit then carries the canonical filter string in `params[:filters]` %>
<%# — no `sendForm` redirect needed. %>
<%# `output_format:` toggles between JSON (one entry per filter, easier to %>
<%# parse server-side) and the URL-style `params` string. Flip the param %>
<%# in the Lookbook sidebar and see the hidden value re-serialise live. %>
<div id="filter-form-preview-hidden">
<%= primer_form_with(url: "/foo", method: :post) do |f| %>
<%=
render(
Filters::FilterForm.new(
f,
query: query,
wrap_with_controller: true,
hidden_input_name: "filters",
output_format: output_format
)
)
%>
<% end %>
<%# Lookbook-only: show what the Stimulus controller is writing into the %>
<%# hidden field. In a real form there's nothing to display — the value %>
<%# rides along with the form submit. %>
<div class="mt-3">
<div class="f6 color-fg-muted mb-1">Hidden field value (live):</div>
<pre class="p-2 color-bg-subtle border rounded-2 f6"
style="white-space: pre-wrap; word-break: break-all;"
id="filter-form-preview-hidden-display">(empty)</pre>
</div>
<script>
(function() {
var root = document.getElementById("filter-form-preview-hidden");
var input = root.querySelector('input[type="hidden"][name="filters"]');
var display = document.getElementById("filter-form-preview-hidden-display");
var last;
// The controller writes to .value directly, which does not fire input
// or change events — poll instead. Cheap and local to this preview.
setInterval(function() {
var v = input.value;
if (v === last) return;
last = v;
display.textContent = v === "" ? "(empty)" : v;
}, 150);
})();
</script>
</div>