Module: Layered::Resource::FiltersHelper
- Defined in:
- app/helpers/layered/resource/filters_helper.rb
Overview
View plumbing for the index filter bar. Filters are plain Ransack
predicates in the query string, so "applying" a filter is just a GET to
the collection path with the current q params plus/minus the filter's
own keys. Select-type controls apply instantly via links; range and
multi-select controls are small GET forms whose hidden fields round-trip
every other q param (search term, sort, other filters) so nothing is
lost across submits — no JavaScript involved.
Picking a filter from the "Add filter" menu doesn't set a value — it
adds the filter as an unset tag whose popover holds the controls.
The top-level f[] param records which tags the user added and in
what order (new tags join the end of the row); it round-trips through
every link and form like the q params do and lives as long as the
tag does — setting a value keeps it, only the tag's ✕ removes it.
Constant Summary collapse
- BOOLEAN_FILTER_COLLECTION =
[["Yes", "true"], ["No", "false"]].freeze
Instance Method Summary collapse
-
#layered_added_filter_attributes ⇒ Object
Declared filters added via the "Add filter" menu, in the order they were added (
f[]in the query string) — set or not, this is the tag row's ordering. -
#layered_auto_open_filter ⇒ Object
The filter whose tag popover should open on this render (via the tag popover's
open:option) — the one-shotfoparam carried only by the "Add filter" menu links. - #layered_filter_active?(filter) ⇒ Boolean
-
#layered_filter_add_path(filter) ⇒ Object
URL the "Add filter" menu links to: current params plus this filter as an unset tag at the end of the row.
-
#layered_filter_apply_path(filter, value) ⇒ Object
URL applying
valuefor a single-select/boolean filter, keeping every otherqparam and dropping pagination. -
#layered_filter_collection(filter) ⇒ Object
Resolved [label, value] option pairs for select-type controls.
-
#layered_filter_combobox_options(filter) ⇒ Object
The
l_ui_comboboxoptions for a filter rendered as a combobox: remote (url:) or local (collection:), never both, plus whatever combobox options the filter declared. -
#layered_filter_control(filter) ⇒ Object
The control this filter actually renders —
filter[:as]for everything but a select, which resolves between the plain list and the combobox. -
#layered_filter_hidden_fields(filter = nil, except: []) ⇒ Object
Hidden inputs carrying every same-scope
qparam except the given filter's own keys (andexcept:extras), plus all thef[]entries (including the submitting filter's own — it keeps the tag's position), so a form's GET submit preserves the search term, sort, the other filters, and the tag row's order. - #layered_filter_label(filter) ⇒ Object
-
#layered_filter_param_keys(filter) ⇒ Object
The
qkeys a filter owns, e.g. - #layered_filter_pending?(filter) ⇒ Boolean
-
#layered_filter_query_params ⇒ Object
The current same-scope Ransack params as a plain string-keyed hash — the working state that apply/remove URLs and hidden fields are built from.
-
#layered_filter_remove_path(filter) ⇒ Object
URL with the filter's own params (and pending
f[]entry) stripped — the tag's ✕ and the controls' Clear links. -
#layered_filter_scope ⇒ Object
The Ransack param scope this page's search object nests under (virtually always "q").
- #layered_filter_selected?(filter, value) ⇒ Boolean
-
#layered_filter_selected_options(filter) ⇒ Object
The filter's current values as [label, value] pairs — the combobox's selections and the tag's text come from the same place, so they always agree.
-
#layered_filter_tag_text(filter) ⇒ Object
Tag text, e.g.
-
#layered_filter_url(filter) ⇒ Object
A filter's
url:, resolved in the view so it can be given as a callable wrapping a path helper (-> { options_users_path }) — the form a resource class can write without route helpers at load time. -
#layered_filtered_collection_path ⇒ Object
The collection path carrying the full current state (
qandf[]), minus pagination. - #layered_filters ⇒ Object
-
#layered_search_clear_path ⇒ Object
Clear link for the search box that drops only the search term, keeping active filters and sort.
-
#layered_search_field_key ⇒ Object
The combined Ransack key the search box posts, mirroring l_ui_search_form's simple mode (
:contpredicate,:orcombinator).
Instance Method Details
#layered_added_filter_attributes ⇒ Object
Declared filters added via the "Add filter" menu, in the order they
were added (f[] in the query string) — set or not, this is the
tag row's ordering. Unknown or stale entries are dropped.
53 54 55 56 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 53 def layered_added_filter_attributes declared = layered_filters.map { |f| f[:attribute].to_s } Array(params[:f]).map(&:to_s).uniq & declared end |
#layered_auto_open_filter ⇒ Object
The filter whose tag popover should open on this render (via the
tag popover's open: option) — the one-shot fo param carried only
by the "Add filter" menu links. Unlike q and f[] it never
round-trips: forms and links rebuild their URLs without it and the
pagy call strips it from page links, so the popover opens once and
stays closed thereafter.
80 81 82 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 80 def layered_auto_open_filter layered_filters.find { |f| f[:attribute].to_s == params[:fo] } if params[:fo].present? end |
#layered_filter_active?(filter) ⇒ Boolean
46 47 48 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 46 def layered_filter_active?(filter) layered_filter_param_keys(filter).any? { |k| layered_filter_query_params[k].present? } end |
#layered_filter_add_path(filter) ⇒ Object
URL the "Add filter" menu links to: current params plus this filter
as an unset tag at the end of the row. The fo param asks that
render to open the new tag's popover, so the controls are ready
without a second click.
67 68 69 70 71 72 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 67 def layered_filter_add_path(filter) attribute = filter[:attribute].to_s layered_filter_path_with(layered_filter_query_params, layered_added_filter_attributes | [attribute], open: attribute) end |
#layered_filter_apply_path(filter, value) ⇒ Object
URL applying value for a single-select/boolean filter, keeping every
other q param and dropping pagination. The filter's f[] entry is
kept — it holds the tag's position in the row.
185 186 187 188 189 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 185 def layered_filter_apply_path(filter, value) q = layered_filter_query_params.except(*layered_filter_param_keys(filter)) q[layered_filter_param_keys(filter).first] = value layered_filter_path_with(q) end |
#layered_filter_collection(filter) ⇒ Object
Resolved [label, value] option pairs for select-type controls. Values are strings so they compare directly against request params. Accepts a collection as an array of values, [label, value] pairs, a callable, or records (from a callable or a belongs_to's default klass.all).
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 92 def layered_filter_collection(filter) return BOOLEAN_FILTER_COLLECTION if filter[:as] == :boolean # Memoised per render: a callable collection (or a belongs_to's default # `klass.all`) is a query, and the control, the size check behind # `layered_filter_control`, and the tag text all ask for it. @_layered_filter_collections ||= {} @_layered_filter_collections[filter[:attribute]] ||= begin raw = filter[:collection] raw = raw.call if raw.respond_to?(:call) raw ||= filter[:reflection]&.klass&.all Array(raw).map do |entry| if entry.is_a?(Array) [entry.first.to_s, entry.last.to_s] elsif entry.respond_to?(:to_key) [layered_filter_record_label(entry), entry.id.to_s] else [entry.to_s, entry.to_s] end end end end |
#layered_filter_combobox_options(filter) ⇒ Object
The l_ui_combobox options for a filter rendered as a combobox:
remote (url:) or local (collection:), never both, plus whatever
combobox options the filter declared. Selections go over as
[label, value] pairs because a remote combobox has no collection in
the browser to look a label up in.
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 147 def (filter) = { label: layered_filter_label(filter), multiple: !!filter[:multiple], selected: (filter) } if (url = layered_filter_url(filter)).present? [:url] = url else [:collection] = layered_filter_collection(filter) end [:min_chars] = filter[:min_chars] if filter[:min_chars] [:text] = filter[:text] if filter[:text] end |
#layered_filter_control(filter) ⇒ Object
The control this filter actually renders — filter[:as] for everything
but a select, which resolves between the plain list and the combobox.
The choice is made here rather than in resolved_filters because it
depends on how many options the collection turns out to hold, and a
callable collection only resolves per request.
A url: filter is always a combobox (its options are fetched as the
user types, so there is nothing to render up front and nothing to
count); a declared as: is honoured as declared; otherwise a list
longer than Layered::Resource.filter_combobox_threshold switches to
the combobox, since neither a checkbox list nor a menu of links scales.
126 127 128 129 130 131 132 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 126 def layered_filter_control(filter) return filter[:as] unless Layered::Resource::Base::SELECT_FILTER_CONTROLS.include?(filter[:as]) return :combobox if filter[:as] == :combobox || filter[:url].present? return :select if filter[:as_declared] layered_filter_collection(filter).size > Layered::Resource.filter_combobox_threshold ? :combobox : :select end |
#layered_filter_hidden_fields(filter = nil, except: []) ⇒ Object
Hidden inputs carrying every same-scope q param except the given
filter's own keys (and except: extras), plus all the f[] entries
(including the submitting filter's own — it keeps the tag's position),
so a form's GET submit preserves the search term, sort, the other
filters, and the tag row's order.
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 216 def layered_filter_hidden_fields(filter = nil, except: []) skip = (filter ? layered_filter_param_keys(filter) : []) + Array(except).map(&:to_s) scope = layered_filter_scope fields = layered_filter_query_params.flat_map do |key, value| next [] if skip.include?(key) || value.is_a?(Hash) name = value.is_a?(Array) ? "#{scope}[#{key}][]" : "#{scope}[#{key}]" Array(value).map { |v| hidden_field_tag(name, v, id: nil) } end fields += layered_added_filter_attributes.map { |attr| hidden_field_tag("f[]", attr, id: nil) } safe_join(fields) end |
#layered_filter_label(filter) ⇒ Object
84 85 86 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 84 def layered_filter_label(filter) filter[:label] || @resource.model.human_attribute_name(filter[:attribute]) end |
#layered_filter_param_keys(filter) ⇒ Object
The q keys a filter owns, e.g. ["status_eq"], ["user_id_in"], or
["created_at_gteq", "created_at_lteq"] for a range.
42 43 44 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 42 def layered_filter_param_keys(filter) filter[:param_keys] end |
#layered_filter_pending?(filter) ⇒ Boolean
58 59 60 61 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 58 def layered_filter_pending?(filter) !layered_filter_active?(filter) && layered_added_filter_attributes.include?(filter[:attribute].to_s) end |
#layered_filter_query_params ⇒ Object
The current same-scope Ransack params as a plain string-keyed hash — the working state that apply/remove URLs and hidden fields are built from. Values are the raw request strings/arrays.
33 34 35 36 37 38 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 33 def layered_filter_query_params raw = params[layered_filter_scope] return {} unless raw.respond_to?(:to_unsafe_h) raw.to_unsafe_h.stringify_keys.transform_values { |v| layered_filter_normalize_value(v) } end |
#layered_filter_remove_path(filter) ⇒ Object
URL with the filter's own params (and pending f[] entry) stripped —
the tag's ✕ and the controls' Clear links. A filter with a default:
writes explicit blanks instead of dropping its keys: an absent key
would just re-apply the default on the next request.
195 196 197 198 199 200 201 202 203 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 195 def layered_filter_remove_path(filter) q = layered_filter_query_params if filter[:default].nil? q = q.except(*layered_filter_param_keys(filter)) else layered_filter_param_keys(filter).each { |k| q[k] = "" } end layered_filter_path_with(q, layered_added_filter_attributes - [filter[:attribute].to_s]) end |
#layered_filter_scope ⇒ Object
The Ransack param scope this page's search object nests under (virtually always "q").
26 27 28 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 26 def layered_filter_scope ((@q && @q.context&.search_key) || :q).to_s end |
#layered_filter_selected?(filter, value) ⇒ Boolean
177 178 179 180 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 177 def layered_filter_selected?(filter, value) current = layered_filter_query_params[layered_filter_param_keys(filter).first] Array(current).map(&:to_s).include?(value.to_s) end |
#layered_filter_selected_options(filter) ⇒ Object
The filter's current values as [label, value] pairs — the combobox's selections and the tag's text come from the same place, so they always agree. Labels come from the collection when there is one; a remote filter has none, so an association's are read from the records themselves and anything else shows the raw value.
168 169 170 171 172 173 174 175 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 168 def (filter) values = Array(layered_filter_query_params[layered_filter_param_keys(filter).first]) .map(&:to_s).reject(&:blank?) return [] if values.empty? labels = layered_filter_option_labels(filter, values) values.map { |value| [labels.fetch(value, value), value] } end |
#layered_filter_tag_text(filter) ⇒ Object
Tag text, e.g. "Status: Published", "User: Alice, Bob", "Created at: 2026-01-01 – 2026-06-30", "Comments count: ≥ 5".
207 208 209 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 207 def layered_filter_tag_text(filter) "#{layered_filter_label(filter)}: #{layered_filter_tag_value(filter)}" end |
#layered_filter_url(filter) ⇒ Object
A filter's url:, resolved in the view so it can be given as a
callable wrapping a path helper (-> { options_users_path }) — the
form a resource class can write without route helpers at load time.
137 138 139 140 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 137 def layered_filter_url(filter) url = filter[:url] url.respond_to?(:call) ? instance_exec(&url) : url end |
#layered_filtered_collection_path ⇒ Object
The collection path carrying the full current state (q and f[]),
minus pagination. Links that rebuild their query from a base URL —
the table's sort links replace only q[s] on it — must start from
this, not the bare collection path, or they drop search and filters.
248 249 250 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 248 def layered_filtered_collection_path layered_filter_path_with(layered_filter_query_params) end |
#layered_filters ⇒ Object
20 21 22 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 20 def layered_filters @_layered_filters ||= @resource.resolved_filters end |
#layered_search_clear_path ⇒ Object
Clear link for the search box that drops only the search term, keeping active filters and sort.
240 241 242 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 240 def layered_search_clear_path layered_filter_path_with(layered_filter_query_params.except(layered_search_field_key)) end |
#layered_search_field_key ⇒ Object
The combined Ransack key the search box posts, mirroring
l_ui_search_form's simple mode (:cont predicate, :or combinator).
234 235 236 |
# File 'app/helpers/layered/resource/filters_helper.rb', line 234 def layered_search_field_key @resource.search_fields.map(&:to_s).join("_or_") + "_cont" end |