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,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