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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
  3. data/AGENTS.md +36 -0
  4. data/CHANGELOG.md +45 -0
  5. data/CLA.md +10 -0
  6. data/LICENSE +201 -0
  7. data/NOTICE +7 -0
  8. data/README.md +912 -0
  9. data/Rakefile +23 -0
  10. data/TRADEMARK.md +31 -0
  11. data/app/controllers/layered/resource/controller.rb +284 -0
  12. data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
  13. data/app/controllers/layered/resource/internal/columns.rb +207 -0
  14. data/app/controllers/layered/resource/internal/routing.rb +60 -0
  15. data/app/controllers/layered/resource/resources_controller.rb +20 -0
  16. data/app/helpers/layered/resource/filters_helper.rb +321 -0
  17. data/app/views/layered/resource/columns/_badge.html.erb +2 -0
  18. data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
  19. data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
  20. data/app/views/layered/resource/columns/_text.html.erb +1 -0
  21. data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
  22. data/app/views/layered/resource/resources/_filters.html.erb +60 -0
  23. data/app/views/layered/resource/resources/edit.html.erb +16 -0
  24. data/app/views/layered/resource/resources/index.html.erb +106 -0
  25. data/app/views/layered/resource/resources/new.html.erb +16 -0
  26. data/app/views/layered/resource/resources/show.html.erb +34 -0
  27. data/config/locales/en.yml +9 -0
  28. data/lib/generators/layered/resource/column/column_generator.rb +63 -0
  29. data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
  30. data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
  31. data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
  32. data/lib/generators/layered/resource/resource_generator.rb +63 -0
  33. data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
  34. data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
  35. data/lib/generators/layered/resource/views/views_generator.rb +49 -0
  36. data/lib/layered/resource/base.rb +625 -0
  37. data/lib/layered/resource/engine.rb +33 -0
  38. data/lib/layered/resource/routing.rb +366 -0
  39. data/lib/layered/resource/version.rb +5 -0
  40. data/lib/layered/resource.rb +61 -0
  41. data/lib/layered-resource-rails.rb +1 -0
  42. 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,2 @@
1
+ <% variant = (!value.nil? && options.fetch(:variants, {})[value.to_s.to_sym]) || :default %>
2
+ <span class="l-ui-badge l-ui-badge--<%= variant %><%= " l-ui-badge--rounded" if options[:rounded] %>"><%= value %></span>
@@ -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>