layered-resource-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
- data/AGENTS.md +36 -0
- data/CHANGELOG.md +45 -0
- data/CLA.md +10 -0
- data/LICENSE +201 -0
- data/NOTICE +7 -0
- data/README.md +912 -0
- data/Rakefile +23 -0
- data/TRADEMARK.md +31 -0
- data/app/controllers/layered/resource/controller.rb +284 -0
- data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
- data/app/controllers/layered/resource/internal/columns.rb +207 -0
- data/app/controllers/layered/resource/internal/routing.rb +60 -0
- data/app/controllers/layered/resource/resources_controller.rb +20 -0
- data/app/helpers/layered/resource/filters_helper.rb +321 -0
- data/app/views/layered/resource/columns/_badge.html.erb +2 -0
- data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
- data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
- data/app/views/layered/resource/columns/_text.html.erb +1 -0
- data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
- data/app/views/layered/resource/resources/_filters.html.erb +60 -0
- data/app/views/layered/resource/resources/edit.html.erb +16 -0
- data/app/views/layered/resource/resources/index.html.erb +106 -0
- data/app/views/layered/resource/resources/new.html.erb +16 -0
- data/app/views/layered/resource/resources/show.html.erb +34 -0
- data/config/locales/en.yml +9 -0
- data/lib/generators/layered/resource/column/column_generator.rb +63 -0
- data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
- data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
- data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
- data/lib/generators/layered/resource/resource_generator.rb +63 -0
- data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
- data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
- data/lib/generators/layered/resource/views/views_generator.rb +49 -0
- data/lib/layered/resource/base.rb +625 -0
- data/lib/layered/resource/engine.rb +33 -0
- data/lib/layered/resource/routing.rb +366 -0
- data/lib/layered/resource/version.rb +5 -0
- data/lib/layered/resource.rb +61 -0
- data/lib/layered-resource-rails.rb +1 -0
- metadata +299 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Resource
|
|
3
|
+
# Default controller for `layered_resources` routes — inherits from the
|
|
4
|
+
# host app's ApplicationController so before_actions like Devise's
|
|
5
|
+
# `authenticate_user!` apply automatically. For an engine that needs
|
|
6
|
+
# the engine's own ApplicationController (and its authorize block),
|
|
7
|
+
# define a sibling controller and include the concern:
|
|
8
|
+
#
|
|
9
|
+
# class Layered::Assistant::ResourcesController < Layered::Assistant::ApplicationController
|
|
10
|
+
# include Layered::Resource::Controller
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# `layered_resources` routes will auto-detect a namespaced controller
|
|
14
|
+
# by name when a `namespace:` option is given (or inferred from the
|
|
15
|
+
# surrounding routes module scope).
|
|
16
|
+
class ResourcesController < ::ApplicationController
|
|
17
|
+
include Controller
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Resource
|
|
3
|
+
# View plumbing for the index filter bar. Filters are plain Ransack
|
|
4
|
+
# predicates in the query string, so "applying" a filter is just a GET to
|
|
5
|
+
# the collection path with the current `q` params plus/minus the filter's
|
|
6
|
+
# own keys. Select-type controls apply instantly via links; range and
|
|
7
|
+
# multi-select controls are small GET forms whose hidden fields round-trip
|
|
8
|
+
# every *other* `q` param (search term, sort, other filters) so nothing is
|
|
9
|
+
# lost across submits — no JavaScript involved.
|
|
10
|
+
#
|
|
11
|
+
# Picking a filter from the "Add filter" menu doesn't set a value — it
|
|
12
|
+
# adds the filter as an *unset* tag whose popover holds the controls.
|
|
13
|
+
# The top-level `f[]` param records which tags the user added and in
|
|
14
|
+
# what order (new tags join the end of the row); it round-trips through
|
|
15
|
+
# every link and form like the `q` params do and lives as long as the
|
|
16
|
+
# tag does — setting a value keeps it, only the tag's ✕ removes it.
|
|
17
|
+
module FiltersHelper
|
|
18
|
+
BOOLEAN_FILTER_COLLECTION = [["Yes", "true"], ["No", "false"]].freeze
|
|
19
|
+
|
|
20
|
+
def layered_filters
|
|
21
|
+
@_layered_filters ||= @resource.resolved_filters
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The Ransack param scope this page's search object nests under
|
|
25
|
+
# (virtually always "q").
|
|
26
|
+
def layered_filter_scope
|
|
27
|
+
((@q && @q.context&.search_key) || :q).to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The current same-scope Ransack params as a plain string-keyed hash —
|
|
31
|
+
# the working state that apply/remove URLs and hidden fields are built
|
|
32
|
+
# from. Values are the raw request strings/arrays.
|
|
33
|
+
def layered_filter_query_params
|
|
34
|
+
raw = params[layered_filter_scope]
|
|
35
|
+
return {} unless raw.respond_to?(:to_unsafe_h)
|
|
36
|
+
|
|
37
|
+
raw.to_unsafe_h.stringify_keys.transform_values { |v| layered_filter_normalize_value(v) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The `q` keys a filter owns, e.g. ["status_eq"], ["user_id_in"], or
|
|
41
|
+
# ["created_at_gteq", "created_at_lteq"] for a range.
|
|
42
|
+
def layered_filter_param_keys(filter)
|
|
43
|
+
filter[:param_keys]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def layered_filter_active?(filter)
|
|
47
|
+
layered_filter_param_keys(filter).any? { |k| layered_filter_query_params[k].present? }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Declared filters added via the "Add filter" menu, in the order they
|
|
51
|
+
# were added (`f[]` in the query string) — set or not, this is the
|
|
52
|
+
# tag row's ordering. Unknown or stale entries are dropped.
|
|
53
|
+
def layered_added_filter_attributes
|
|
54
|
+
declared = layered_filters.map { |f| f[:attribute].to_s }
|
|
55
|
+
Array(params[:f]).map(&:to_s).uniq & declared
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def layered_filter_pending?(filter)
|
|
59
|
+
!layered_filter_active?(filter) &&
|
|
60
|
+
layered_added_filter_attributes.include?(filter[:attribute].to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# URL the "Add filter" menu links to: current params plus this filter
|
|
64
|
+
# as an unset tag at the end of the row. The `fo` param asks that
|
|
65
|
+
# render to open the new tag's popover, so the controls are ready
|
|
66
|
+
# without a second click.
|
|
67
|
+
def layered_filter_add_path(filter)
|
|
68
|
+
attribute = filter[:attribute].to_s
|
|
69
|
+
layered_filter_path_with(layered_filter_query_params,
|
|
70
|
+
layered_added_filter_attributes | [attribute],
|
|
71
|
+
open: attribute)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The filter whose tag popover should open on this render (via the
|
|
75
|
+
# tag popover's `open:` option) — the one-shot `fo` param carried only
|
|
76
|
+
# by the "Add filter" menu links. Unlike `q` and `f[]` it never
|
|
77
|
+
# round-trips: forms and links rebuild their URLs without it and the
|
|
78
|
+
# pagy call strips it from page links, so the popover opens once and
|
|
79
|
+
# stays closed thereafter.
|
|
80
|
+
def layered_auto_open_filter
|
|
81
|
+
layered_filters.find { |f| f[:attribute].to_s == params[:fo] } if params[:fo].present?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def layered_filter_label(filter)
|
|
85
|
+
filter[:label] || @resource.model.human_attribute_name(filter[:attribute])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Resolved [label, value] option pairs for select-type controls. Values
|
|
89
|
+
# are strings so they compare directly against request params. Accepts
|
|
90
|
+
# a collection as an array of values, [label, value] pairs, a callable,
|
|
91
|
+
# or records (from a callable or a belongs_to's default klass.all).
|
|
92
|
+
def layered_filter_collection(filter)
|
|
93
|
+
return BOOLEAN_FILTER_COLLECTION if filter[:as] == :boolean
|
|
94
|
+
|
|
95
|
+
# Memoised per render: a callable collection (or a belongs_to's default
|
|
96
|
+
# `klass.all`) is a query, and the control, the size check behind
|
|
97
|
+
# `layered_filter_control`, and the tag text all ask for it.
|
|
98
|
+
@_layered_filter_collections ||= {}
|
|
99
|
+
@_layered_filter_collections[filter[:attribute]] ||= begin
|
|
100
|
+
raw = filter[:collection]
|
|
101
|
+
raw = raw.call if raw.respond_to?(:call)
|
|
102
|
+
raw ||= filter[:reflection]&.klass&.all
|
|
103
|
+
Array(raw).map do |entry|
|
|
104
|
+
if entry.is_a?(Array)
|
|
105
|
+
[entry.first.to_s, entry.last.to_s]
|
|
106
|
+
elsif entry.respond_to?(:to_key)
|
|
107
|
+
[layered_filter_record_label(entry), entry.id.to_s]
|
|
108
|
+
else
|
|
109
|
+
[entry.to_s, entry.to_s]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The control this filter actually renders — `filter[:as]` for everything
|
|
116
|
+
# but a select, which resolves between the plain list and the combobox.
|
|
117
|
+
# The choice is made here rather than in `resolved_filters` because it
|
|
118
|
+
# depends on how many options the collection turns out to hold, and a
|
|
119
|
+
# callable collection only resolves per request.
|
|
120
|
+
#
|
|
121
|
+
# A `url:` filter is always a combobox (its options are fetched as the
|
|
122
|
+
# user types, so there is nothing to render up front and nothing to
|
|
123
|
+
# count); a declared `as:` is honoured as declared; otherwise a list
|
|
124
|
+
# longer than `Layered::Resource.filter_combobox_threshold` switches to
|
|
125
|
+
# the combobox, since neither a checkbox list nor a menu of links scales.
|
|
126
|
+
def layered_filter_control(filter)
|
|
127
|
+
return filter[:as] unless Layered::Resource::Base::SELECT_FILTER_CONTROLS.include?(filter[:as])
|
|
128
|
+
return :combobox if filter[:as] == :combobox || filter[:url].present?
|
|
129
|
+
return :select if filter[:as_declared]
|
|
130
|
+
|
|
131
|
+
layered_filter_collection(filter).size > Layered::Resource.filter_combobox_threshold ? :combobox : :select
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A filter's `url:`, resolved in the view so it can be given as a
|
|
135
|
+
# callable wrapping a path helper (`-> { options_users_path }`) — the
|
|
136
|
+
# form a resource class can write without route helpers at load time.
|
|
137
|
+
def layered_filter_url(filter)
|
|
138
|
+
url = filter[:url]
|
|
139
|
+
url.respond_to?(:call) ? instance_exec(&url) : url
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The `l_ui_combobox` options for a filter rendered as a combobox:
|
|
143
|
+
# remote (`url:`) or local (`collection:`), never both, plus whatever
|
|
144
|
+
# combobox options the filter declared. Selections go over as
|
|
145
|
+
# [label, value] pairs because a remote combobox has no collection in
|
|
146
|
+
# the browser to look a label up in.
|
|
147
|
+
def layered_filter_combobox_options(filter)
|
|
148
|
+
options = {
|
|
149
|
+
label: layered_filter_label(filter),
|
|
150
|
+
multiple: !!filter[:multiple],
|
|
151
|
+
selected: layered_filter_selected_options(filter)
|
|
152
|
+
}
|
|
153
|
+
if (url = layered_filter_url(filter)).present?
|
|
154
|
+
options[:url] = url
|
|
155
|
+
else
|
|
156
|
+
options[:collection] = layered_filter_collection(filter)
|
|
157
|
+
end
|
|
158
|
+
options[:min_chars] = filter[:min_chars] if filter[:min_chars]
|
|
159
|
+
options[:text] = filter[:text] if filter[:text]
|
|
160
|
+
options
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The filter's current values as [label, value] pairs — the combobox's
|
|
164
|
+
# selections and the tag's text come from the same place, so they always
|
|
165
|
+
# agree. Labels come from the collection when there is one; a remote
|
|
166
|
+
# filter has none, so an association's are read from the records
|
|
167
|
+
# themselves and anything else shows the raw value.
|
|
168
|
+
def layered_filter_selected_options(filter)
|
|
169
|
+
values = Array(layered_filter_query_params[layered_filter_param_keys(filter).first])
|
|
170
|
+
.map(&:to_s).reject(&:blank?)
|
|
171
|
+
return [] if values.empty?
|
|
172
|
+
|
|
173
|
+
labels = layered_filter_option_labels(filter, values)
|
|
174
|
+
values.map { |value| [labels.fetch(value, value), value] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def layered_filter_selected?(filter, value)
|
|
178
|
+
current = layered_filter_query_params[layered_filter_param_keys(filter).first]
|
|
179
|
+
Array(current).map(&:to_s).include?(value.to_s)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# URL applying `value` for a single-select/boolean filter, keeping every
|
|
183
|
+
# other `q` param and dropping pagination. The filter's `f[]` entry is
|
|
184
|
+
# kept — it holds the tag's position in the row.
|
|
185
|
+
def layered_filter_apply_path(filter, value)
|
|
186
|
+
q = layered_filter_query_params.except(*layered_filter_param_keys(filter))
|
|
187
|
+
q[layered_filter_param_keys(filter).first] = value
|
|
188
|
+
layered_filter_path_with(q)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# URL with the filter's own params (and pending `f[]` entry) stripped —
|
|
192
|
+
# the tag's ✕ and the controls' Clear links. A filter with a `default:`
|
|
193
|
+
# writes explicit blanks instead of dropping its keys: an absent key
|
|
194
|
+
# would just re-apply the default on the next request.
|
|
195
|
+
def layered_filter_remove_path(filter)
|
|
196
|
+
q = layered_filter_query_params
|
|
197
|
+
if filter[:default].nil?
|
|
198
|
+
q = q.except(*layered_filter_param_keys(filter))
|
|
199
|
+
else
|
|
200
|
+
layered_filter_param_keys(filter).each { |k| q[k] = "" }
|
|
201
|
+
end
|
|
202
|
+
layered_filter_path_with(q, layered_added_filter_attributes - [filter[:attribute].to_s])
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Tag text, e.g. "Status: Published", "User: Alice, Bob",
|
|
206
|
+
# "Created at: 2026-01-01 – 2026-06-30", "Comments count: ≥ 5".
|
|
207
|
+
def layered_filter_tag_text(filter)
|
|
208
|
+
"#{layered_filter_label(filter)}: #{layered_filter_tag_value(filter)}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Hidden inputs carrying every same-scope `q` param except the given
|
|
212
|
+
# filter's own keys (and `except:` extras), plus all the `f[]` entries
|
|
213
|
+
# (including the submitting filter's own — it keeps the tag's position),
|
|
214
|
+
# so a form's GET submit preserves the search term, sort, the other
|
|
215
|
+
# filters, and the tag row's order.
|
|
216
|
+
def layered_filter_hidden_fields(filter = nil, except: [])
|
|
217
|
+
skip = (filter ? layered_filter_param_keys(filter) : []) + Array(except).map(&:to_s)
|
|
218
|
+
scope = layered_filter_scope
|
|
219
|
+
|
|
220
|
+
fields = layered_filter_query_params.flat_map do |key, value|
|
|
221
|
+
next [] if skip.include?(key) || value.is_a?(Hash)
|
|
222
|
+
|
|
223
|
+
name = value.is_a?(Array) ? "#{scope}[#{key}][]" : "#{scope}[#{key}]"
|
|
224
|
+
Array(value).map { |v| hidden_field_tag(name, v, id: nil) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
fields += layered_added_filter_attributes.map { |attr| hidden_field_tag("f[]", attr, id: nil) }
|
|
228
|
+
|
|
229
|
+
safe_join(fields)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The combined Ransack key the search box posts, mirroring
|
|
233
|
+
# l_ui_search_form's simple mode (`:cont` predicate, `:or` combinator).
|
|
234
|
+
def layered_search_field_key
|
|
235
|
+
@resource.search_fields.map(&:to_s).join("_or_") + "_cont"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Clear link for the search box that drops only the search term,
|
|
239
|
+
# keeping active filters and sort.
|
|
240
|
+
def layered_search_clear_path
|
|
241
|
+
layered_filter_path_with(layered_filter_query_params.except(layered_search_field_key))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The collection path carrying the full current state (`q` and `f[]`),
|
|
245
|
+
# minus pagination. Links that rebuild their query from a base URL —
|
|
246
|
+
# the table's sort links replace only `q[s]` on it — must start from
|
|
247
|
+
# this, not the bare collection path, or they drop search and filters.
|
|
248
|
+
def layered_filtered_collection_path
|
|
249
|
+
layered_filter_path_with(layered_filter_query_params)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def layered_filter_path_with(q, added = layered_added_filter_attributes, open: nil)
|
|
255
|
+
query = {}
|
|
256
|
+
query[layered_filter_scope] = q if q.present?
|
|
257
|
+
query[:f] = added if added.present?
|
|
258
|
+
query[:fo] = open if open.present?
|
|
259
|
+
path = layered_collection_path
|
|
260
|
+
query.present? ? "#{path}?#{query.to_query}" : path
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def layered_filter_tag_value(filter)
|
|
264
|
+
case filter[:as]
|
|
265
|
+
when :select, :combobox, :boolean
|
|
266
|
+
layered_filter_selected_options(filter).map(&:first).join(", ")
|
|
267
|
+
when :date_range, :range
|
|
268
|
+
from_key, to_key = layered_filter_param_keys(filter)
|
|
269
|
+
from = layered_filter_query_params[from_key]
|
|
270
|
+
to = layered_filter_query_params[to_key]
|
|
271
|
+
if from.present? && to.present?
|
|
272
|
+
"#{from} – #{to}"
|
|
273
|
+
elsif from.present?
|
|
274
|
+
"≥ #{from}"
|
|
275
|
+
else
|
|
276
|
+
"≤ #{to}"
|
|
277
|
+
end
|
|
278
|
+
else
|
|
279
|
+
layered_filter_query_params[layered_filter_param_keys(filter).first]
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Value => label for the filter's currently-set values. A remote filter
|
|
284
|
+
# has no collection to read them from, so an association's are looked up
|
|
285
|
+
# by id; anything else (a remote filter over a plain column) resolves to
|
|
286
|
+
# nothing and the value stands in for its own label.
|
|
287
|
+
def layered_filter_option_labels(filter, values)
|
|
288
|
+
if filter[:url].present?
|
|
289
|
+
reflection = filter[:reflection]
|
|
290
|
+
return {} if reflection.nil?
|
|
291
|
+
|
|
292
|
+
reflection.klass.where(id: values)
|
|
293
|
+
.to_h { |record| [record.id.to_s, layered_filter_record_label(record)] }
|
|
294
|
+
else
|
|
295
|
+
layered_filter_collection(filter).to_h { |label, value| [value, label] }
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Drops the blank value a combobox posts ahead of its selections (there
|
|
300
|
+
# so that clearing every token submits an empty collection rather than
|
|
301
|
+
# omitting the parameter — Ransack prunes it from the query itself). An
|
|
302
|
+
# array left with nothing becomes an explicit blank, the same shape the
|
|
303
|
+
# Clear link writes, so a cleared filter reads as inactive and a
|
|
304
|
+
# `default:` doesn't immediately re-apply.
|
|
305
|
+
def layered_filter_normalize_value(value)
|
|
306
|
+
return value unless value.is_a?(Array)
|
|
307
|
+
|
|
308
|
+
pruned = value.reject(&:blank?)
|
|
309
|
+
pruned.empty? ? "" : pruned
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Label for a record in a belongs_to select when no `collection:` is
|
|
313
|
+
# given. The associated model is another resource's business, not this
|
|
314
|
+
# one's, so there is no `label_attribute` to ask for: the shared
|
|
315
|
+
# fallbacks (name/title/label/email, else "Model #id") do the labelling.
|
|
316
|
+
def layered_filter_record_label(record)
|
|
317
|
+
Layered::Resource.record_label(record)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= value ? options.fetch(:true_label, "✓") : options.fetch(:false_label, "✗") %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= value&.strftime(options[:format] || "%-d %b %Y %H:%M") %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= value.respond_to?(:strftime) ? value.strftime("%-d %b %Y %H:%M") : value %>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<%# One filter's editing UI, rendered inside its tag's popover.
|
|
2
|
+
Short single-selects and booleans apply instantly via links; multi-selects,
|
|
3
|
+
comboboxes, ranges, and text filters are GET forms whose hidden fields
|
|
4
|
+
round-trip every other q param (search, sort, other filters) and the other
|
|
5
|
+
unset tags' f[] entries, so submits compose.
|
|
6
|
+
|
|
7
|
+
`layered_filter_control` — not `filter[:as]` — decides which control a
|
|
8
|
+
select renders as: a long option list (or a remote `url:` one) becomes a
|
|
9
|
+
type-ahead combobox, which needs the form even when single-choice. %>
|
|
10
|
+
<% scope = layered_filter_scope
|
|
11
|
+
control = layered_filter_control(filter)
|
|
12
|
+
active = layered_filter_active?(filter)
|
|
13
|
+
frame_data = { turbo_frame: turbo_frame_name, turbo_action: "advance" } %>
|
|
14
|
+
|
|
15
|
+
<% if [:select, :boolean].include?(control) && !filter[:multiple] %>
|
|
16
|
+
<div class="l-ui-popover__menu">
|
|
17
|
+
<% layered_filter_collection(filter).each do |label, value| %>
|
|
18
|
+
<% selected = layered_filter_selected?(filter, value) %>
|
|
19
|
+
<%= link_to layered_filter_apply_path(filter, value),
|
|
20
|
+
class: "l-ui-popover__menu-item",
|
|
21
|
+
aria: (selected ? { current: true } : {}),
|
|
22
|
+
data: frame_data do %>
|
|
23
|
+
<%= label %><%= " ✓" if selected %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% if active %>
|
|
27
|
+
<hr class="l-ui-popover__menu-divider">
|
|
28
|
+
<%= link_to "Clear", layered_filter_remove_path(filter),
|
|
29
|
+
class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger", data: frame_data %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
<% else %>
|
|
33
|
+
<%= form_with url: layered_collection_path, method: :get,
|
|
34
|
+
class: "l-ui-form", data: frame_data do %>
|
|
35
|
+
<%= layered_filter_hidden_fields(filter) %>
|
|
36
|
+
|
|
37
|
+
<% if control == :combobox %>
|
|
38
|
+
<%# The combobox names the bare key: it appends its own `[]` when
|
|
39
|
+
multiple, so a single-choice one posts a scalar the `eq` predicate
|
|
40
|
+
can use. Its leading blank hidden value means clearing every token
|
|
41
|
+
and applying submits an empty filter rather than omitting the key. %>
|
|
42
|
+
<% key = layered_filter_param_keys(filter).first %>
|
|
43
|
+
<%= l_ui_combobox("#{scope}[#{key}]",
|
|
44
|
+
id: "layered-filter-#{@layered_route_key}-#{filter[:attribute]}-combobox",
|
|
45
|
+
**layered_filter_combobox_options(filter)) %>
|
|
46
|
+
<% elsif control == :select %>
|
|
47
|
+
<% key = layered_filter_param_keys(filter).first %>
|
|
48
|
+
<% layered_filter_collection(filter).each_with_index do |(label, value), index| %>
|
|
49
|
+
<%# The wrapping label makes the whole row toggle the checkbox, so the
|
|
50
|
+
row gets the pointer too (no l-ui class carries just a cursor). %>
|
|
51
|
+
<label class="l-ui-checkbox-container <%= index.zero? ? "l-ui-mt-0" : "l-ui-mt-3" %>" style="cursor: pointer">
|
|
52
|
+
<%= check_box_tag "#{scope}[#{key}][]", value,
|
|
53
|
+
layered_filter_selected?(filter, value), id: nil, class: "l-ui-checkbox" %>
|
|
54
|
+
<span class="l-ui-checkbox-container__label"><%= label %></span>
|
|
55
|
+
</label>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% elsif [:date_range, :range].include?(filter[:as]) %>
|
|
58
|
+
<% from_key, to_key = layered_filter_param_keys(filter) %>
|
|
59
|
+
<% field_tag = filter[:as] == :date_range ? :date_field_tag : :number_field_tag %>
|
|
60
|
+
<% field_options = filter[:as] == :range ? { step: "any" } : {} %>
|
|
61
|
+
<label>
|
|
62
|
+
From
|
|
63
|
+
<%= send(field_tag, "#{scope}[#{from_key}]", layered_filter_query_params[from_key],
|
|
64
|
+
{ id: nil, class: "l-ui-form__field" }.merge(field_options)) %>
|
|
65
|
+
</label>
|
|
66
|
+
<label class="l-ui-form__group">
|
|
67
|
+
To
|
|
68
|
+
<%= send(field_tag, "#{scope}[#{to_key}]", layered_filter_query_params[to_key],
|
|
69
|
+
{ id: nil, class: "l-ui-form__field" }.merge(field_options)) %>
|
|
70
|
+
</label>
|
|
71
|
+
<% else %>
|
|
72
|
+
<% key = layered_filter_param_keys(filter).first %>
|
|
73
|
+
<label>
|
|
74
|
+
Contains
|
|
75
|
+
<%= text_field_tag "#{scope}[#{key}]", layered_filter_query_params[key],
|
|
76
|
+
id: nil, class: "l-ui-form__field" %>
|
|
77
|
+
</label>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
<hr class="l-ui-popover__menu-divider l-ui-mt-3">
|
|
81
|
+
<%= button_tag "Apply", name: nil, class: "l-ui-popover__menu-item" %>
|
|
82
|
+
<% if active %>
|
|
83
|
+
<%= link_to "Clear", layered_filter_remove_path(filter),
|
|
84
|
+
class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger", data: frame_data %>
|
|
85
|
+
<% end %>
|
|
86
|
+
<% end %>
|
|
87
|
+
<% end %>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<%# The filter bar. Picking a filter from the "Add filter" popover adds it as
|
|
2
|
+
an unset tag (tracked by the f[] param) with its popover already open,
|
|
3
|
+
ready to take a value; pressing a tag's label reopens the popover, and
|
|
4
|
+
its ✕ removes it. Once a value is set the tag persists via its own q
|
|
5
|
+
params and the f[] entry drops away. The bar lives inside the index turbo
|
|
6
|
+
frame, so adding, applying, changing, or removing a filter is a single
|
|
7
|
+
frame navigation that reloads the table and re-renders the tags. %>
|
|
8
|
+
<%# Tag order: pinned first (declaration order), then filters active straight
|
|
9
|
+
from the URL (e.g. a bookmarked query), then user-added tags in the order
|
|
10
|
+
they were added (the f[] list) — so a new tag always joins the end. %>
|
|
11
|
+
<% pinned, unpinned = layered_filters.partition { |f| f[:pinned] }
|
|
12
|
+
added = layered_added_filter_attributes
|
|
13
|
+
tags = pinned +
|
|
14
|
+
unpinned.select { |f| layered_filter_active?(f) && !added.include?(f[:attribute].to_s) } +
|
|
15
|
+
added.filter_map { |attr| unpinned.find { |f| f[:attribute].to_s == attr } }
|
|
16
|
+
available = layered_filters - tags %>
|
|
17
|
+
|
|
18
|
+
<div class="l-ui-mt-3 l-ui-tag-row">
|
|
19
|
+
<% tags.each do |filter| %>
|
|
20
|
+
<%= l_ui_tag do |t| %>
|
|
21
|
+
<% t.button(aria: { label: "Edit #{layered_filter_label(filter)} filter" }) do %>
|
|
22
|
+
<%= layered_filter_active?(filter) ? layered_filter_tag_text(filter) : layered_filter_label(filter) %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<%# A pinned tag is always shown, so it has no remove ✕ — its popover's
|
|
25
|
+
Clear resets the value but the tag stays. %>
|
|
26
|
+
<% unless filter[:pinned] %>
|
|
27
|
+
<% t.remove layered_filter_remove_path(filter),
|
|
28
|
+
aria: { label: "Remove #{layered_filter_label(filter)} filter" },
|
|
29
|
+
data: { turbo_frame: turbo_frame_name, turbo_action: "advance" } %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<%# `open` renders a freshly added tag (the one-shot `fo` param from the
|
|
32
|
+
add-filter link) with its popover already open, ready for a value. %>
|
|
33
|
+
<% t.popover id: "layered-filter-#{@layered_route_key}-#{filter[:attribute]}",
|
|
34
|
+
open: filter == layered_auto_open_filter do %>
|
|
35
|
+
<%= render "filter_control", filter: filter, turbo_frame_name: turbo_frame_name %>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<% if available.any? %>
|
|
41
|
+
<%= l_ui_popover do |p| %>
|
|
42
|
+
<% p.trigger(class: "l-ui-button l-ui-button--outline l-ui-button--small") do %>
|
|
43
|
+
<%# Inline plus (stroke follows the button's text colour) — a text "+"
|
|
44
|
+
is capped at the button's text-xs and reads too small. %>
|
|
45
|
+
<svg class="l-ui-icon--xs" fill="none" stroke="currentColor" stroke-width="2"
|
|
46
|
+
viewBox="0 0 24 24" aria-hidden="true">
|
|
47
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
|
|
48
|
+
</svg>
|
|
49
|
+
Add filter
|
|
50
|
+
<% end %>
|
|
51
|
+
<div class="l-ui-popover__menu">
|
|
52
|
+
<% available.each do |filter| %>
|
|
53
|
+
<%= link_to layered_filter_label(filter), layered_filter_add_path(filter),
|
|
54
|
+
class: "l-ui-popover__menu-item",
|
|
55
|
+
data: { turbo_frame: turbo_frame_name, turbo_action: "advance" } %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div class="l-ui-spread">
|
|
2
|
+
<div>
|
|
3
|
+
<%= l_ui_breadcrumbs do %>
|
|
4
|
+
<% layered_breadcrumbs.each do |crumb| %>
|
|
5
|
+
<%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<h1 class="l-ui-heading">Edit <%= @model.model_name.human.downcase %></h1>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="l-ui-mt-2">
|
|
15
|
+
<%= l_ui_form(@record, fields: @fields, url: @form_url, method: :patch) %>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<% turbo_frame_name = "layered_#{@layered_route_key}" %>
|
|
2
|
+
|
|
3
|
+
<div class="l-ui-spread">
|
|
4
|
+
<div>
|
|
5
|
+
<% if layered_breadcrumbs.any? %>
|
|
6
|
+
<%= l_ui_breadcrumbs do %>
|
|
7
|
+
<% layered_breadcrumbs.each do |crumb| %>
|
|
8
|
+
<%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<h1 class="l-ui-heading">
|
|
14
|
+
<%= @model.model_name.human.pluralize %>
|
|
15
|
+
</h1>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% if resource_can?(:new) %>
|
|
19
|
+
<%= link_to "New",
|
|
20
|
+
layered_collection_path(action: :new),
|
|
21
|
+
class: "l-ui-button l-ui-button--primary" %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<% introduction_partial = "layered/#{@layered_resource_name}/introduction" %>
|
|
26
|
+
<% if lookup_context.exists?(introduction_partial, [], true) %>
|
|
27
|
+
<%= render introduction_partial %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<%= turbo_frame_tag turbo_frame_name, data: { turbo_action: "advance" } do %>
|
|
31
|
+
<% if @resource.search_fields.any? %>
|
|
32
|
+
<div class="l-ui-mt-4">
|
|
33
|
+
<% if layered_filters.any? %>
|
|
34
|
+
<%# With filters declared, the search form must round-trip the active
|
|
35
|
+
filter and sort q params as hidden fields so its submit composes
|
|
36
|
+
with them; Clear drops only the search term. %>
|
|
37
|
+
<%= l_ui_search_form(@q, url: layered_collection_path, turbo_frame: turbo_frame_name) do |f| %>
|
|
38
|
+
<%= layered_filter_hidden_fields(except: layered_search_field_key) %>
|
|
39
|
+
<%= f.label layered_search_field_key, "Search", class: "l-ui-sr-only" %>
|
|
40
|
+
<div class="l-ui-search-inline">
|
|
41
|
+
<%= f.text_field layered_search_field_key,
|
|
42
|
+
class: "l-ui-form__field", placeholder: @resource.search_placeholder %>
|
|
43
|
+
<%= f.submit "Search", class: "l-ui-button l-ui-button--primary" %>
|
|
44
|
+
<%= link_to "Clear", layered_search_clear_path,
|
|
45
|
+
class: "l-ui-button l-ui-button--outline",
|
|
46
|
+
data: { turbo_frame: turbo_frame_name, turbo_action: "advance" } %>
|
|
47
|
+
</div>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% else %>
|
|
50
|
+
<%= l_ui_search_form(@q,
|
|
51
|
+
url: layered_collection_path,
|
|
52
|
+
fields: @resource.search_fields,
|
|
53
|
+
placeholder: @resource.search_placeholder,
|
|
54
|
+
clear: true,
|
|
55
|
+
turbo_frame: turbo_frame_name) %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
|
|
60
|
+
<% if layered_filters.any? %>
|
|
61
|
+
<%= render "filters", turbo_frame_name: turbo_frame_name %>
|
|
62
|
+
<% end %>
|
|
63
|
+
|
|
64
|
+
<% actions_proc = if @resource_can_edit || @resource_can_destroy
|
|
65
|
+
->(record) do
|
|
66
|
+
items = []
|
|
67
|
+
if resource_can?(:edit, record)
|
|
68
|
+
items << link_to("Edit", layered_member_path(record, action: :edit),
|
|
69
|
+
class: "l-ui-popover__menu-item",
|
|
70
|
+
data: { turbo_frame: "_top" })
|
|
71
|
+
end
|
|
72
|
+
if resource_can?(:destroy, record)
|
|
73
|
+
items << tag.hr(class: "l-ui-popover__menu-divider") if items.any?
|
|
74
|
+
items << button_to("Delete", layered_member_path(record),
|
|
75
|
+
method: :delete,
|
|
76
|
+
class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger",
|
|
77
|
+
data: { turbo_confirm: "Are you sure?", turbo_frame: "_top" })
|
|
78
|
+
end
|
|
79
|
+
next if items.empty?
|
|
80
|
+
|
|
81
|
+
l_ui_popover(align: :end) do |p|
|
|
82
|
+
p.trigger(class: "l-ui-button l-ui-button--outline l-ui-button--icon l-ui-button--small",
|
|
83
|
+
aria: { label: "Actions for #{layered_record_label(record)}" }) do
|
|
84
|
+
image_tag("layered_ui/icon_more.svg", alt: "",
|
|
85
|
+
class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true })
|
|
86
|
+
end
|
|
87
|
+
tag.div(safe_join(items), class: "l-ui-popover__menu")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end %>
|
|
91
|
+
|
|
92
|
+
<div class="l-ui-mt-4">
|
|
93
|
+
<%# The sort links rebuild their query from this base URL, so it must
|
|
94
|
+
carry the current search, filters, and unset tags. %>
|
|
95
|
+
<%= l_ui_table(@records,
|
|
96
|
+
columns: @columns,
|
|
97
|
+
caption: @layered_route_key.humanize,
|
|
98
|
+
query: @q,
|
|
99
|
+
url: layered_filtered_collection_path,
|
|
100
|
+
actions: actions_proc,
|
|
101
|
+
floating_actions: true,
|
|
102
|
+
turbo_frame: turbo_frame_name) %>
|
|
103
|
+
|
|
104
|
+
<%= l_ui_pagy(@pagy) %>
|
|
105
|
+
</div>
|
|
106
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div class="l-ui-spread">
|
|
2
|
+
<div>
|
|
3
|
+
<%= l_ui_breadcrumbs do %>
|
|
4
|
+
<% layered_breadcrumbs.each do |crumb| %>
|
|
5
|
+
<%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<h1 class="l-ui-heading">New <%= @model.model_name.human.downcase %></h1>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="l-ui-mt-2">
|
|
15
|
+
<%= l_ui_form(@record, fields: @fields, url: @form_url) %>
|
|
16
|
+
</div>
|