mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Add Lookbook for Filter Forms
This commit is contained in:
@@ -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
|
||||
+25
@@ -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>
|
||||
Reference in New Issue
Block a user