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

Instance Method Details

#layered_added_filter_attributesObject

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_filterObject

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

Returns:

  • (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 layered_filter_combobox_options(filter)
  options = {
    label: layered_filter_label(filter),
    multiple: !!filter[:multiple],
    selected: layered_filter_selected_options(filter)
  }
  if (url = layered_filter_url(filter)).present?
    options[:url] = url
  else
    options[:collection] = layered_filter_collection(filter)
  end
  options[:min_chars] = filter[:min_chars] if filter[:min_chars]
  options[:text] = filter[:text] if filter[:text]
  options
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

Returns:

  • (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_paramsObject

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_scopeObject

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

Returns:

  • (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 layered_filter_selected_options(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_pathObject

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_filtersObject



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_pathObject

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_keyObject

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