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,625 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Resource
|
|
3
|
+
class Base
|
|
4
|
+
# The controls that pick a value out of a list, and so share a
|
|
5
|
+
# collection, the `_in`/`_eq` predicates, and the automatic switch from
|
|
6
|
+
# the plain list to the combobox once the list gets long.
|
|
7
|
+
SELECT_FILTER_CONTROLS = %i[select combobox].freeze
|
|
8
|
+
|
|
9
|
+
# `l_ui_combobox` options a filter may declare and have passed straight
|
|
10
|
+
# through to the control. The write-side options (`create:`,
|
|
11
|
+
# `create_name:`, `reorder:`) are absent by design: a filter chooses
|
|
12
|
+
# among existing values, it never invents one.
|
|
13
|
+
COMBOBOX_FILTER_OPTIONS = %i[url min_chars text].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def model(klass = nil)
|
|
17
|
+
if klass
|
|
18
|
+
@model = klass
|
|
19
|
+
elsif instance_variable_defined?(:@model)
|
|
20
|
+
@model
|
|
21
|
+
elsif superclass < Layered::Resource::Base
|
|
22
|
+
superclass.model
|
|
23
|
+
else
|
|
24
|
+
@model = name.delete_suffix("Resource").constantize
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def columns(value = nil)
|
|
29
|
+
if value
|
|
30
|
+
@columns = value
|
|
31
|
+
else
|
|
32
|
+
inherited_attribute(:@columns) || [{ attribute: :id }]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def search_fields(value = nil)
|
|
37
|
+
if value
|
|
38
|
+
@search_fields = value
|
|
39
|
+
else
|
|
40
|
+
inherited_attribute(:@search_fields) || []
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def search_placeholder(value = nil)
|
|
45
|
+
if value
|
|
46
|
+
@search_placeholder = value
|
|
47
|
+
else
|
|
48
|
+
inherited_attribute(:@search_placeholder) || default_search_placeholder
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Declares a static crumb rendered before any derived breadcrumbs —
|
|
53
|
+
# typically a link back to the host app's dashboard:
|
|
54
|
+
#
|
|
55
|
+
# root_breadcrumb "Home", "/"
|
|
56
|
+
#
|
|
57
|
+
# Top-level resources otherwise have no trail at all; nested routes
|
|
58
|
+
# prepend this crumb to the derived parent trail.
|
|
59
|
+
def root_breadcrumb(label = nil, path = nil)
|
|
60
|
+
if label
|
|
61
|
+
@root_breadcrumb = { label: label, path: path }
|
|
62
|
+
else
|
|
63
|
+
inherited_attribute(:@root_breadcrumb)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def default_sort(value = nil)
|
|
68
|
+
if value.is_a?(Hash)
|
|
69
|
+
@default_sort = value
|
|
70
|
+
else
|
|
71
|
+
inherited_attribute(:@default_sort) || { attribute: :id, direction: :desc }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def per_page(value = nil)
|
|
76
|
+
if value
|
|
77
|
+
@per_page = value
|
|
78
|
+
else
|
|
79
|
+
inherited_attribute(:@per_page) || 15
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fields(value = nil)
|
|
84
|
+
if value
|
|
85
|
+
@fields = value
|
|
86
|
+
else
|
|
87
|
+
inherited_attribute(:@fields) || []
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The fields as the form layer wants them: each one's `required:`
|
|
92
|
+
# resolved from its validators unless declared, and `permit:` dropped.
|
|
93
|
+
# `permit:` is strong-parameters configuration read by
|
|
94
|
+
# `permitted_params`; the form helper passes any key it does not
|
|
95
|
+
# recognise through to the field's input, where a stray `permit`
|
|
96
|
+
# renders as an HTML attribute (on a `select` or text input) or raises
|
|
97
|
+
# (on a `combobox`, whose helper takes named options only).
|
|
98
|
+
def resolved_fields
|
|
99
|
+
fields.map do |field|
|
|
100
|
+
field = infer_association_field(field.except(:permit))
|
|
101
|
+
next field if field.key?(:required)
|
|
102
|
+
|
|
103
|
+
field.merge(required: attribute_required?(field[:attribute]))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The attribute one of this resource's records is labelled by - in a
|
|
108
|
+
# page title, a row's actions menu, or as an option in another
|
|
109
|
+
# resource's picker. Defaults to the primary column (the column marked
|
|
110
|
+
# `primary: true`, else the first), which is the label the index
|
|
111
|
+
# already leads each row with. Declare it when that column is not the
|
|
112
|
+
# record's name - a `primary:` column rendered by a `render:` proc,
|
|
113
|
+
# say, or one that is not the record's own attribute at all:
|
|
114
|
+
#
|
|
115
|
+
# label_attribute :title
|
|
116
|
+
def label_attribute(value = nil)
|
|
117
|
+
if value
|
|
118
|
+
@label_attribute = value
|
|
119
|
+
else
|
|
120
|
+
inherited_attribute(:@label_attribute) ||
|
|
121
|
+
(columns.find { |c| c[:primary] } || columns.first)&.fetch(:attribute, nil)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def record_label(record)
|
|
126
|
+
Layered::Resource.record_label(record, attribute: label_attribute)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Declares structured filter controls for the index table. Each entry
|
|
130
|
+
# is either a bare attribute (control + Ransack predicate inferred from
|
|
131
|
+
# the column type, enum, or association) or an attribute with an
|
|
132
|
+
# options hash overriding the inference:
|
|
133
|
+
#
|
|
134
|
+
# filters :status, # enum -> multi-select of its values
|
|
135
|
+
# :featured, # boolean -> Yes / No
|
|
136
|
+
# :created_at, # datetime -> from / to range
|
|
137
|
+
# :comments_count, # integer -> number range
|
|
138
|
+
# :user # belongs_to -> multi-select
|
|
139
|
+
#
|
|
140
|
+
# Select-type filters (enum, belongs_to, collection) default to
|
|
141
|
+
# multi-select via the `in` predicate; pass `multiple: false` for a
|
|
142
|
+
# single-choice `eq` select. Their control depends on how many options
|
|
143
|
+
# there turn out to be: up to `Layered::Resource
|
|
144
|
+
# .filter_combobox_threshold` (10) they render as the plain list, past
|
|
145
|
+
# it as a type-ahead combobox — a checkbox list of every user is no way
|
|
146
|
+
# to pick one. Declaring `as:` pins the control either way.
|
|
147
|
+
#
|
|
148
|
+
# Recognised override keys: `as:` (control type), `collection:` (select
|
|
149
|
+
# options — an array, an array of [label, value] pairs, or a callable
|
|
150
|
+
# resolved per request), `multiple:` (multi-select via the `in`
|
|
151
|
+
# predicate), `label:`, `pinned:` (tag always shown, never in the
|
|
152
|
+
# add-filter menu, no remove ✕), and `default:` (value applied when
|
|
153
|
+
# the request carries none — a scalar, `{ from:, to: }` for ranges,
|
|
154
|
+
# or a callable resolved per request), plus the combobox options in
|
|
155
|
+
# COMBOBOX_FILTER_OPTIONS — `url:` (fetch options from an endpoint as
|
|
156
|
+
# the user types, instead of rendering a collection up front),
|
|
157
|
+
# `min_chars:`, and `text:`. A `url:` filter is always a combobox:
|
|
158
|
+
# there is no collection to render or count.
|
|
159
|
+
def filters(*entries)
|
|
160
|
+
if entries.empty?
|
|
161
|
+
inherited_attribute(:@filters) || []
|
|
162
|
+
else
|
|
163
|
+
@filters = entries
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Normalises `filters` into an array of control descriptors the view
|
|
168
|
+
# layer renders and `patch_ransack` allowlists. Each descriptor carries
|
|
169
|
+
# the Ransack attribute it keys on (`ransack_attribute` — the foreign
|
|
170
|
+
# key for association filters), the inferred control (`as`), and the
|
|
171
|
+
# collection/predicate metadata the control needs.
|
|
172
|
+
def resolved_filters
|
|
173
|
+
normalize_filter_entries(filters).map { |attribute, opts| build_filter(attribute, opts) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The own-model column names a filter set needs allowlisted for Ransack
|
|
177
|
+
# (e.g. `status`, `created_at`, `user_id`). Association filters resolve
|
|
178
|
+
# to the foreign-key column, so no association walk/join is required.
|
|
179
|
+
def filter_attributes
|
|
180
|
+
resolved_filters.map { |f| f[:ransack_attribute].to_s }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Builds the args for `params.permit(*permitted_params)`. Each field
|
|
184
|
+
# is permitted as a scalar by default. A `permit:` entry on a field
|
|
185
|
+
# opts that field into the hash form: `permit: []` allows array
|
|
186
|
+
# values (e.g. `documents: []` for `has_many_attached`), and
|
|
187
|
+
# `permit: [:street, :city]` allows a nested hash with those keys
|
|
188
|
+
# (e.g. `address_attributes: [:street, :city]` for accepts_nested).
|
|
189
|
+
def permitted_params
|
|
190
|
+
fields.map do |f|
|
|
191
|
+
if f.key?(:permit)
|
|
192
|
+
{ f[:attribute] => f[:permit] }
|
|
193
|
+
else
|
|
194
|
+
f[:attribute]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def requires_distinct?
|
|
200
|
+
model.ransackable_associations(self).any? do |assoc|
|
|
201
|
+
model.reflect_on_association(assoc)&.collection?
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Resolves `search_fields` entries that aren't columns on the
|
|
206
|
+
# resource's own model but match Ransack's association-walk form
|
|
207
|
+
# `<association>_<attribute>` (e.g. `:user_name` searches
|
|
208
|
+
# `users.name` from a `belongs_to :user`). Returns hashes of
|
|
209
|
+
# { association:, attribute:, klass: }. Longer association names
|
|
210
|
+
# win when prefixes overlap (e.g. `author_profile_` over
|
|
211
|
+
# `author_`), mirroring Ransack's greedy resolution.
|
|
212
|
+
def association_search_fields
|
|
213
|
+
reflections = model.reflect_on_all_associations
|
|
214
|
+
.reject(&:polymorphic?)
|
|
215
|
+
.sort_by { |r| -r.name.length }
|
|
216
|
+
|
|
217
|
+
search_fields.filter_map do |field|
|
|
218
|
+
field = field.to_s
|
|
219
|
+
next if model.column_names.include?(field)
|
|
220
|
+
|
|
221
|
+
reflection = reflections.find do |r|
|
|
222
|
+
field.start_with?("#{r.name}_") &&
|
|
223
|
+
r.klass.column_names.include?(field.delete_prefix("#{r.name}_"))
|
|
224
|
+
end
|
|
225
|
+
next unless reflection
|
|
226
|
+
|
|
227
|
+
{
|
|
228
|
+
association: reflection.name.to_s,
|
|
229
|
+
attribute: field.delete_prefix("#{reflection.name}_"),
|
|
230
|
+
klass: reflection.klass
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def scope(controller)
|
|
236
|
+
if pundit_enabled?
|
|
237
|
+
controller.send(:policy_scope, model)
|
|
238
|
+
else
|
|
239
|
+
model.all
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_record(controller)
|
|
244
|
+
scope(controller).build
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Declares an ownership relationship between the resource's model and
|
|
248
|
+
# an object the controller can produce (typically the signed-in user
|
|
249
|
+
# or the current tenant).
|
|
250
|
+
#
|
|
251
|
+
# owned_by :user # via :current_user
|
|
252
|
+
# owned_by :account, via: :current_account
|
|
253
|
+
#
|
|
254
|
+
# Behavioural shorthand for two override patterns at once:
|
|
255
|
+
# - `scope` scopes records to the owner.
|
|
256
|
+
# - `build_record` assigns the owner on new records.
|
|
257
|
+
#
|
|
258
|
+
# By default a nil owner (e.g. `current_user` returns nil because
|
|
259
|
+
# auth wasn't wired up) raises loudly so the misconfiguration surfaces
|
|
260
|
+
# immediately. Pass `allow_nil: true` for genuinely public-with-scope
|
|
261
|
+
# behaviour, in which case `scope` falls back to `model.none` and
|
|
262
|
+
# `build_record` assigns nil. `use_pundit` takes over `scope` for the
|
|
263
|
+
# read filter (Policy::Scope#resolve wins) but `owned_by` still drives
|
|
264
|
+
# owner assignment on create.
|
|
265
|
+
def owned_by(association, via: :current_user, allow_nil: false)
|
|
266
|
+
@owned_by = { association: association, via: via, allow_nil: allow_nil }
|
|
267
|
+
|
|
268
|
+
# Pundit guards auth at the policy layer (policy.create?, etc.),
|
|
269
|
+
# so when use_pundit is enabled we let nil owners pass and let
|
|
270
|
+
# Pundit raise NotAuthorizedError. Without Pundit, we raise
|
|
271
|
+
# MissingOwnerError on nil unless allow_nil: true.
|
|
272
|
+
resolve_owner = lambda do |controller|
|
|
273
|
+
owner = controller.public_send(via)
|
|
274
|
+
if owner.nil? && !allow_nil && !pundit_enabled?
|
|
275
|
+
raise Layered::Resource::MissingOwnerError,
|
|
276
|
+
"#{name}#owned_by(:#{association}) expected #{via} to return an owner but got nil. " \
|
|
277
|
+
"Ensure authentication is configured (e.g. before_action :authenticate_user!), " \
|
|
278
|
+
"or pass `allow_nil: true` to opt into public-with-scope behaviour."
|
|
279
|
+
end
|
|
280
|
+
owner
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
define_singleton_method(:scope) do |controller|
|
|
284
|
+
if pundit_enabled?
|
|
285
|
+
controller.send(:policy_scope, model)
|
|
286
|
+
else
|
|
287
|
+
owner = resolve_owner.call(controller)
|
|
288
|
+
owner.nil? ? model.none : model.where(association => owner)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
define_singleton_method(:build_record) do |controller|
|
|
293
|
+
owner = resolve_owner.call(controller)
|
|
294
|
+
base = pundit_enabled? ? model : scope(controller)
|
|
295
|
+
base.new(association => owner)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Opts the resource into Pundit. When enabled:
|
|
300
|
+
# - `scope(controller)` is `Pundit.policy_scope(current_user, model)`.
|
|
301
|
+
# - The controller calls `authorize(@record)` after loading a member
|
|
302
|
+
# record (show/edit/update/destroy) — Pundit raises on denial.
|
|
303
|
+
# - The `@resource_can_*` route-exposure flags are ANDed with the
|
|
304
|
+
# class-level policy (e.g. `policy(model).new?`) so action buttons
|
|
305
|
+
# hide automatically for users who can't perform the action.
|
|
306
|
+
#
|
|
307
|
+
# Per-record visibility (e.g. "this user can edit *this* record") is
|
|
308
|
+
# available in views via the `resource_can?(:update, record)` helper,
|
|
309
|
+
# which composes the route-exposure flag with the per-record policy.
|
|
310
|
+
def use_pundit
|
|
311
|
+
@use_pundit = true
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def pundit_enabled?
|
|
315
|
+
inherited_attribute(:@use_pundit) == true
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def after_save_path(controller, _record)
|
|
319
|
+
controller.layered_collection_path
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def field_type_for(attribute)
|
|
323
|
+
col = model.columns_hash[attribute.to_s]
|
|
324
|
+
return :string unless col
|
|
325
|
+
|
|
326
|
+
case col.type
|
|
327
|
+
when :text then :text
|
|
328
|
+
when :integer, :float, :decimal then :number
|
|
329
|
+
when :boolean then :checkbox
|
|
330
|
+
when :date then :date
|
|
331
|
+
when :datetime then :datetime
|
|
332
|
+
else :string
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def configure_ransack
|
|
337
|
+
patch_ransack(model)
|
|
338
|
+
# An association-walking search field (e.g. `:user_name`) is the
|
|
339
|
+
# consumer explicitly referencing the associated model, so it also
|
|
340
|
+
# gets the scoped patch — Ransack asks the *associated* model for
|
|
341
|
+
# its ransackable_attributes when resolving the walk.
|
|
342
|
+
association_search_fields.map { |a| a[:klass] }.uniq.each { |k| patch_ransack(k) }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
# Labels each search field via human_attribute_name so host-app i18n
|
|
348
|
+
# (activerecord.attributes.<model>.<attr>) flows through. Association
|
|
349
|
+
# walks label as "<association> <attribute>", each half resolved
|
|
350
|
+
# against its own model's human names.
|
|
351
|
+
def default_search_placeholder
|
|
352
|
+
walks = association_search_fields.index_by { |a| "#{a[:association]}_#{a[:attribute]}" }
|
|
353
|
+
|
|
354
|
+
labels = search_fields.map do |field|
|
|
355
|
+
if (walk = walks[field.to_s])
|
|
356
|
+
"#{model.human_attribute_name(walk[:association]).downcase} " \
|
|
357
|
+
"#{walk[:klass].human_attribute_name(walk[:attribute]).downcase}"
|
|
358
|
+
else
|
|
359
|
+
model.human_attribute_name(field).downcase
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
"Search by #{labels.join(', ')}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Installs scoped ransackable_attributes/ransackable_associations on
|
|
367
|
+
# `m`. The overrides only answer when the `auth_object` is a layered
|
|
368
|
+
# resource that references `m` — either as its own model, or via an
|
|
369
|
+
# association-walking search field. Every other caller falls through
|
|
370
|
+
# to the methods captured below (whether host-defined or the framework
|
|
371
|
+
# default), so any allowlist the host app has set up is preserved.
|
|
372
|
+
def patch_ransack(m)
|
|
373
|
+
return if m.instance_variable_get(:@_layered_resource_ransack_configured)
|
|
374
|
+
|
|
375
|
+
original_attributes = m.method(:ransackable_attributes)
|
|
376
|
+
original_associations = m.method(:ransackable_associations)
|
|
377
|
+
# Detect a host-defined override anywhere in the model's singleton
|
|
378
|
+
# ancestry (Post, ApplicationRecord, ActiveRecord::Base, etc.).
|
|
379
|
+
# Ransack supplies the default via a regular Module mixin, so its
|
|
380
|
+
# `owner` is a Module but not a Class; an explicit `def self.x` in
|
|
381
|
+
# any host class produces a singleton-class owner, which is a Class.
|
|
382
|
+
host_attributes_defined = original_attributes.owner.is_a?(Class)
|
|
383
|
+
host_associations_defined = original_associations.owner.is_a?(Class)
|
|
384
|
+
|
|
385
|
+
m.define_singleton_method(:ransackable_attributes) do |auth_object = nil|
|
|
386
|
+
unless auth_object.is_a?(Class) && auth_object < Layered::Resource::Base
|
|
387
|
+
next original_attributes.call(auth_object)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if auth_object.model == self
|
|
391
|
+
db_columns = column_names
|
|
392
|
+
attrs = auth_object.columns.map { |c| c[:attribute].to_s }.select { |a| db_columns.include?(a) }
|
|
393
|
+
sort_attr = auth_object.default_sort[:attribute].to_s
|
|
394
|
+
attrs |= [sort_attr] if db_columns.include?(sort_attr)
|
|
395
|
+
walks = auth_object.association_search_fields.map { |a| "#{a[:association]}_#{a[:attribute]}" }
|
|
396
|
+
# Association-walking entries must NOT be allowlisted as
|
|
397
|
+
# attributes here: Ransack treats an allowlisted name as a
|
|
398
|
+
# literal column and emits `<table>.user_name` instead of
|
|
399
|
+
# joining into the association.
|
|
400
|
+
attrs |= (auth_object.search_fields.map(&:to_s) - walks)
|
|
401
|
+
# Filter controls key on own-model columns (enum/boolean/date/
|
|
402
|
+
# number columns, or a belongs_to's foreign key), so they fold
|
|
403
|
+
# straight into the attribute allowlist alongside search fields.
|
|
404
|
+
attrs |= auth_object.filter_attributes.select { |a| db_columns.include?(a) }
|
|
405
|
+
# Self-referential walks (e.g. `parent_title` on a Post
|
|
406
|
+
# `belongs_to :parent, class_name: "Post"`) land in this branch
|
|
407
|
+
# too — the associated klass IS the resource's model — so the
|
|
408
|
+
# walked attribute must be allowlisted here, not in the
|
|
409
|
+
# other-resource branch below.
|
|
410
|
+
attrs | auth_object.association_search_fields
|
|
411
|
+
.select { |a| a[:klass] == self }
|
|
412
|
+
.map { |a| a[:attribute] }
|
|
413
|
+
else
|
|
414
|
+
# A different resource asking about this model: answer only for
|
|
415
|
+
# the attributes its association-walking search fields target
|
|
416
|
+
# here (e.g. PostResource searching `user_name` asks User for
|
|
417
|
+
# `name`), folding in the host's own allowlist when one exists.
|
|
418
|
+
declared = auth_object.association_search_fields
|
|
419
|
+
.select { |a| a[:klass] == self }
|
|
420
|
+
.map { |a| a[:attribute] }
|
|
421
|
+
if declared.any?
|
|
422
|
+
base = host_attributes_defined ? original_attributes.call(auth_object) : []
|
|
423
|
+
base | declared
|
|
424
|
+
else
|
|
425
|
+
original_attributes.call(auth_object)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Associations become ransackable only when the resource explicitly
|
|
431
|
+
# references them via an association-walking search field, or when
|
|
432
|
+
# the host has defined its own allowlist. Everything else stays
|
|
433
|
+
# narrow: requests like `q[s]=user_name asc` are silently ignored
|
|
434
|
+
# rather than 500ing.
|
|
435
|
+
m.define_singleton_method(:ransackable_associations) do |auth_object = nil|
|
|
436
|
+
if auth_object.is_a?(Class) && auth_object < Layered::Resource::Base && auth_object.model == self
|
|
437
|
+
base = host_associations_defined ? original_associations.call(auth_object) : []
|
|
438
|
+
base | auth_object.association_search_fields.map { |a| a[:association] }
|
|
439
|
+
else
|
|
440
|
+
original_associations.call(auth_object)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
m.instance_variable_set(:@_layered_resource_ransack_configured, true)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Walks the resource class ancestry to find the first ancestor that
|
|
448
|
+
# has the given ivar set. Class-level ivars are not inherited in Ruby,
|
|
449
|
+
# so we explicitly walk to give subclasses access to a parent's
|
|
450
|
+
# declared columns/fields/etc. without redeclaring them.
|
|
451
|
+
def inherited_attribute(ivar)
|
|
452
|
+
klass = self
|
|
453
|
+
while klass && klass <= Layered::Resource::Base
|
|
454
|
+
return klass.instance_variable_get(ivar) if klass.instance_variable_defined?(ivar)
|
|
455
|
+
|
|
456
|
+
klass = klass.superclass
|
|
457
|
+
end
|
|
458
|
+
nil
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Flattens the mixed positional/keyword `filters` form into
|
|
462
|
+
# [attribute, options] pairs: bare symbols become [attr, {}]; a
|
|
463
|
+
# trailing hash maps each attribute to its options hash.
|
|
464
|
+
def normalize_filter_entries(entries)
|
|
465
|
+
entries.flat_map do |entry|
|
|
466
|
+
if entry.is_a?(Hash)
|
|
467
|
+
entry.map { |attribute, opts| [attribute.to_sym, (opts || {}).symbolize_keys] }
|
|
468
|
+
else
|
|
469
|
+
[[entry.to_sym, {}]]
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Resolves one filter entry into a control descriptor, inferring the
|
|
475
|
+
# control type and Ransack predicate from a belongs_to association, an
|
|
476
|
+
# ActiveRecord enum, or the column type — unless an explicit `as:`
|
|
477
|
+
# override is supplied.
|
|
478
|
+
def build_filter(attribute, opts)
|
|
479
|
+
reflection = belongs_to_reflection(attribute)
|
|
480
|
+
label = opts[:label]
|
|
481
|
+
|
|
482
|
+
descriptor =
|
|
483
|
+
if reflection
|
|
484
|
+
select_filter(attribute, reflection.foreign_key.to_sym, opts.fetch(:multiple, true),
|
|
485
|
+
opts[:collection], label, reflection: reflection)
|
|
486
|
+
elsif model.defined_enums.key?(attribute.to_s)
|
|
487
|
+
# Values are the enum's DB representation, not its keys — Ransack
|
|
488
|
+
# compares the raw column, so `status_in[]=1` matches while
|
|
489
|
+
# `status_in[]=published` would depend on attribute-type casting.
|
|
490
|
+
collection = opts[:collection] || model.defined_enums[attribute.to_s].map { |k, v| [k.humanize, v] }
|
|
491
|
+
select_filter(attribute, attribute, opts.fetch(:multiple, true), collection, label)
|
|
492
|
+
else
|
|
493
|
+
as = opts[:as] || default_filter_as(field_type_for(attribute), opts)
|
|
494
|
+
multiple = opts.fetch(:multiple, SELECT_FILTER_CONTROLS.include?(as))
|
|
495
|
+
typed_filter(attribute, as, multiple, opts[:collection], label)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# An association or enum filter infers `as: :select`, so a declared
|
|
499
|
+
# `as: :combobox` has to be applied over the top: the predicate and
|
|
500
|
+
# `multiple:` are the same either way, only the control differs.
|
|
501
|
+
descriptor = descriptor.merge(as: :combobox) if opts[:as] == :combobox
|
|
502
|
+
|
|
503
|
+
predicates = descriptor[:predicates] ? descriptor[:predicates].values : [descriptor[:predicate]]
|
|
504
|
+
descriptor.merge(
|
|
505
|
+
param_keys: predicates.map { |p| "#{descriptor[:ransack_attribute]}_#{p}" },
|
|
506
|
+
pinned: opts.fetch(:pinned, false),
|
|
507
|
+
default: opts[:default],
|
|
508
|
+
# Whether the control was named rather than inferred. A declared
|
|
509
|
+
# `as: :select` pins the plain list, opting out of the automatic
|
|
510
|
+
# switch to a combobox once the option list gets long.
|
|
511
|
+
as_declared: opts.key?(:as),
|
|
512
|
+
**opts.slice(*COMBOBOX_FILTER_OPTIONS)
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def belongs_to_reflection(attribute)
|
|
517
|
+
reflection = model.reflect_on_association(attribute)
|
|
518
|
+
reflection if reflection&.belongs_to? && !reflection.polymorphic?
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Default control for a column type when no `as:` override is given.
|
|
522
|
+
# A string/text column only becomes a select when a `collection:` is
|
|
523
|
+
# supplied, otherwise it falls back to a "contains" text filter.
|
|
524
|
+
def default_filter_as(type, opts)
|
|
525
|
+
case type
|
|
526
|
+
when :checkbox then :boolean
|
|
527
|
+
when :date, :datetime then :date_range
|
|
528
|
+
when :number then :range
|
|
529
|
+
else opts.key?(:collection) || opts[:url] ? :select : :string
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def select_filter(attribute, ransack_attribute, multiple, collection, label, reflection: nil)
|
|
534
|
+
{
|
|
535
|
+
attribute: attribute,
|
|
536
|
+
ransack_attribute: ransack_attribute,
|
|
537
|
+
as: :select,
|
|
538
|
+
predicate: multiple ? :in : :eq,
|
|
539
|
+
multiple: multiple,
|
|
540
|
+
collection: collection,
|
|
541
|
+
reflection: reflection,
|
|
542
|
+
label: label
|
|
543
|
+
}
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def typed_filter(attribute, as, multiple, collection, label)
|
|
547
|
+
base = { attribute: attribute, ransack_attribute: attribute, as: as,
|
|
548
|
+
multiple: multiple, collection: collection, label: label }
|
|
549
|
+
case as
|
|
550
|
+
when *SELECT_FILTER_CONTROLS then base.merge(predicate: multiple ? :in : :eq)
|
|
551
|
+
when :boolean then base.merge(predicate: :eq)
|
|
552
|
+
when :string then base.merge(predicate: :cont)
|
|
553
|
+
when :date_range, :range then base.merge(predicates: { from: :gteq, to: :lteq })
|
|
554
|
+
else base.merge(predicate: :eq)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# An attribute is treated as required when it has a presence validator
|
|
559
|
+
# that runs unconditionally on every save. Conditional (:if/:unless),
|
|
560
|
+
# context-scoped (:on), and "skip when blank/nil" validators don't
|
|
561
|
+
# qualify because they may not fire for the form being rendered.
|
|
562
|
+
def attribute_required?(attribute)
|
|
563
|
+
# A `belongs_to` validates the presence of the *association*, not of
|
|
564
|
+
# the foreign key the form posts, so the picker for one would always
|
|
565
|
+
# look optional. Its validator is no use here either: Rails attaches
|
|
566
|
+
# an `if:` that fires only while the key is nil or changed - an
|
|
567
|
+
# internal optimisation, not conditionality the app asked for - which
|
|
568
|
+
# the unconditional-only rule below would reject. The association's
|
|
569
|
+
# own `optional:` is the declaration to read, resolved the way
|
|
570
|
+
# ActiveRecord resolves it.
|
|
571
|
+
if (reflection = belongs_to_reflection_for_key(attribute))
|
|
572
|
+
optional = reflection.options[:optional]
|
|
573
|
+
return optional.nil? ? !!model.belongs_to_required_by_default : !optional
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
model.validators_on(attribute).any? { |v|
|
|
577
|
+
v.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
|
578
|
+
v.options.slice(:if, :unless, :on, :allow_nil, :allow_blank).empty?
|
|
579
|
+
}
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# A field naming a `belongs_to`'s foreign key is a record picker, so it
|
|
583
|
+
# renders as a single-select combobox over the associated records rather
|
|
584
|
+
# than as the number the column happens to hold. Declaring `as:` opts
|
|
585
|
+
# out, and `collection:` replaces the options (to scope or order them,
|
|
586
|
+
# or to label them differently) while keeping the control.
|
|
587
|
+
#
|
|
588
|
+
# The default collection is a callable, so the query runs per request
|
|
589
|
+
# rather than once at boot, and each record is labelled by the shared
|
|
590
|
+
# `Layered::Resource.record_label`. Note that this does *not* consult
|
|
591
|
+
# the associated model's own resource for its `label_attribute`: a
|
|
592
|
+
# model can have several resources (a plain one and an admin variant,
|
|
593
|
+
# say), so there is no single resource to ask. Pass `collection:` to
|
|
594
|
+
# label by one deliberately:
|
|
595
|
+
#
|
|
596
|
+
# { attribute: :user_id,
|
|
597
|
+
# collection: -> { User.kept.map { |u| [UserResource.record_label(u), u.id] } } }
|
|
598
|
+
def infer_association_field(field)
|
|
599
|
+
return field if field[:as]
|
|
600
|
+
|
|
601
|
+
reflection = belongs_to_reflection_for_key(field[:attribute])
|
|
602
|
+
return field if reflection.nil?
|
|
603
|
+
|
|
604
|
+
collection = field[:collection] || lambda do
|
|
605
|
+
reflection.klass.all.map { |record| [Layered::Resource.record_label(record), record.id] }
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
{ as: :combobox, multiple: false, collection: collection }.merge(field)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# The `belongs_to` a field/attribute name is the foreign key of, if any.
|
|
612
|
+
# Polymorphic associations are skipped: there is no single class whose
|
|
613
|
+
# records could fill a picker.
|
|
614
|
+
def belongs_to_reflection_for_key(key)
|
|
615
|
+
key = key.to_s
|
|
616
|
+
return nil unless model.column_names.include?(key)
|
|
617
|
+
|
|
618
|
+
model.reflect_on_all_associations(:belongs_to)
|
|
619
|
+
.reject(&:polymorphic?)
|
|
620
|
+
.find { |reflection| reflection.foreign_key.to_s == key }
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Resource
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
isolate_namespace Layered::Resource
|
|
5
|
+
|
|
6
|
+
initializer "layered-resource-rails.autoload", before: :set_autoload_paths do |app|
|
|
7
|
+
app.config.autoload_paths += [Rails.root.join("app/layered_resources").to_s]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
initializer "layered-resource-rails.routing", before: :add_routing_paths do
|
|
11
|
+
ActionDispatch::Routing::Mapper.include(Layered::Resource::Routing)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "layered-resource-rails.pagy" do
|
|
15
|
+
if defined?(Pagy)
|
|
16
|
+
ActiveSupport.on_load(:action_controller) do
|
|
17
|
+
include Pagy::Method
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "layered-resource-rails.locales" do |app|
|
|
23
|
+
config.i18n.load_path += Dir[Engine.root.join("config/locales/*.yml")]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "layered-resource-rails.view_paths" do
|
|
27
|
+
ActiveSupport.on_load(:action_controller) do
|
|
28
|
+
append_view_path Engine.root.join("app/views")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|