Class: Layered::Resource::Base

Inherits:
Object
  • Object
show all
Defined in:
lib/layered/resource/base.rb

Constant Summary collapse

SELECT_FILTER_CONTROLS =

The controls that pick a value out of a list, and so share a collection, the _in/_eq predicates, and the automatic switch from the plain list to the combobox once the list gets long.

%i[select combobox].freeze
COMBOBOX_FILTER_OPTIONS =

l_ui_combobox options a filter may declare and have passed straight through to the control. The write-side options (create:, create_name:, reorder:) are absent by design: a filter chooses among existing values, it never invents one.

%i[url min_chars text].freeze

Class Method Summary collapse

Class Method Details

.after_save_path(controller, _record) ⇒ Object



318
319
320
# File 'lib/layered/resource/base.rb', line 318

def after_save_path(controller, _record)
  controller.layered_collection_path
end

.association_search_fieldsObject

Resolves search_fields entries that aren't columns on the resource's own model but match Ransack's association-walk form <association>_<attribute> (e.g. :user_name searches users.name from a belongs_to :user). Returns hashes of { association:, attribute:, klass: }. Longer association names win when prefixes overlap (e.g. author_profile_ over author_), mirroring Ransack's greedy resolution.



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/layered/resource/base.rb', line 212

def association_search_fields
  reflections = model.reflect_on_all_associations
                     .reject(&:polymorphic?)
                     .sort_by { |r| -r.name.length }

  search_fields.filter_map do |field|
    field = field.to_s
    next if model.column_names.include?(field)

    reflection = reflections.find do |r|
      field.start_with?("#{r.name}_") &&
        r.klass.column_names.include?(field.delete_prefix("#{r.name}_"))
    end
    next unless reflection

    {
      association: reflection.name.to_s,
      attribute: field.delete_prefix("#{reflection.name}_"),
      klass: reflection.klass
    }
  end
end

.build_record(controller) ⇒ Object



243
244
245
# File 'lib/layered/resource/base.rb', line 243

def build_record(controller)
  scope(controller).build
end

.columns(value = nil) ⇒ Object



28
29
30
31
32
33
34
# File 'lib/layered/resource/base.rb', line 28

def columns(value = nil)
  if value
    @columns = value
  else
    inherited_attribute(:@columns) || [{ attribute: :id }]
  end
end

.configure_ransackObject



336
337
338
339
340
341
342
343
# File 'lib/layered/resource/base.rb', line 336

def configure_ransack
  patch_ransack(model)
  # An association-walking search field (e.g. `:user_name`) is the
  # consumer explicitly referencing the associated model, so it also
  # gets the scoped patch — Ransack asks the *associated* model for
  # its ransackable_attributes when resolving the walk.
  association_search_fields.map { |a| a[:klass] }.uniq.each { |k| patch_ransack(k) }
end

.default_sort(value = nil) ⇒ Object



67
68
69
70
71
72
73
# File 'lib/layered/resource/base.rb', line 67

def default_sort(value = nil)
  if value.is_a?(Hash)
    @default_sort = value
  else
    inherited_attribute(:@default_sort) || { attribute: :id, direction: :desc }
  end
end

.field_type_for(attribute) ⇒ Object



322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/layered/resource/base.rb', line 322

def field_type_for(attribute)
  col = model.columns_hash[attribute.to_s]
  return :string unless col

  case col.type
  when :text then :text
  when :integer, :float, :decimal then :number
  when :boolean then :checkbox
  when :date then :date
  when :datetime then :datetime
  else :string
  end
end

.fields(value = nil) ⇒ Object



83
84
85
86
87
88
89
# File 'lib/layered/resource/base.rb', line 83

def fields(value = nil)
  if value
    @fields = value
  else
    inherited_attribute(:@fields) || []
  end
end

.filter_attributesObject

The own-model column names a filter set needs allowlisted for Ransack (e.g. status, created_at, user_id). Association filters resolve to the foreign-key column, so no association walk/join is required.



179
180
181
# File 'lib/layered/resource/base.rb', line 179

def filter_attributes
  resolved_filters.map { |f| f[:ransack_attribute].to_s }
end

.filters(*entries) ⇒ Object

Declares structured filter controls for the index table. Each entry is either a bare attribute (control + Ransack predicate inferred from the column type, enum, or association) or an attribute with an options hash overriding the inference:

filters :status,                       # enum     -> multi-select of its values
      :featured,                     # boolean  -> Yes / No
      :created_at,                   # datetime -> from / to range
      :comments_count,               # integer  -> number range
      :user                          # belongs_to -> multi-select

Select-type filters (enum, belongs_to, collection) default to multi-select via the in predicate; pass multiple: false for a single-choice eq select. Their control depends on how many options there turn out to be: up to Layered::Resource .filter_combobox_threshold (10) they render as the plain list, past it as a type-ahead combobox — a checkbox list of every user is no way to pick one. Declaring as: pins the control either way.

Recognised override keys: as: (control type), collection: (select options — an array, an array of [label, value] pairs, or a callable resolved per request), multiple: (multi-select via the in predicate), label:, pinned: (tag always shown, never in the add-filter menu, no remove ✕), and default: (value applied when the request carries none — a scalar, { from:, to: } for ranges, or a callable resolved per request), plus the combobox options in COMBOBOX_FILTER_OPTIONS — url: (fetch options from an endpoint as the user types, instead of rendering a collection up front), min_chars:, and text:. A url: filter is always a combobox: there is no collection to render or count.



159
160
161
162
163
164
165
# File 'lib/layered/resource/base.rb', line 159

def filters(*entries)
  if entries.empty?
    inherited_attribute(:@filters) || []
  else
    @filters = entries
  end
end

.label_attribute(value = nil) ⇒ Object

The attribute one of this resource's records is labelled by - in a page title, a row's actions menu, or as an option in another resource's picker. Defaults to the primary column (the column marked primary: true, else the first), which is the label the index already leads each row with. Declare it when that column is not the record's name - a primary: column rendered by a render: proc, say, or one that is not the record's own attribute at all:

label_attribute :title


116
117
118
119
120
121
122
123
# File 'lib/layered/resource/base.rb', line 116

def label_attribute(value = nil)
  if value
    @label_attribute = value
  else
    inherited_attribute(:@label_attribute) ||
      (columns.find { |c| c[:primary] } || columns.first)&.fetch(:attribute, nil)
  end
end

.model(klass = nil) ⇒ Object



16
17
18
19
20
21
22
23
24
25
26
# File 'lib/layered/resource/base.rb', line 16

def model(klass = nil)
  if klass
    @model = klass
  elsif instance_variable_defined?(:@model)
    @model
  elsif superclass < Layered::Resource::Base
    superclass.model
  else
    @model = name.delete_suffix("Resource").constantize
  end
end

.owned_by(association, via: :current_user, allow_nil: false) ⇒ Object

Declares an ownership relationship between the resource's model and an object the controller can produce (typically the signed-in user or the current tenant).

owned_by :user                 # via :current_user
owned_by :account, via: :current_account

Behavioural shorthand for two override patterns at once:

- `scope`        scopes records to the owner.
- `build_record` assigns the owner on new records.

By default a nil owner (e.g. current_user returns nil because auth wasn't wired up) raises loudly so the misconfiguration surfaces immediately. Pass allow_nil: true for genuinely public-with-scope behaviour, in which case scope falls back to model.none and build_record assigns nil. use_pundit takes over scope for the read filter (Policy::Scope#resolve wins) but owned_by still drives owner assignment on create.



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/layered/resource/base.rb', line 265

def owned_by(association, via: :current_user, allow_nil: false)
  @owned_by = { association: association, via: via, allow_nil: allow_nil }

  # Pundit guards auth at the policy layer (policy.create?, etc.),
  # so when use_pundit is enabled we let nil owners pass and let
  # Pundit raise NotAuthorizedError. Without Pundit, we raise
  # MissingOwnerError on nil unless allow_nil: true.
  resolve_owner = lambda do |controller|
    owner = controller.public_send(via)
    if owner.nil? && !allow_nil && !pundit_enabled?
      raise Layered::Resource::MissingOwnerError,
            "#{name}#owned_by(:#{association}) expected #{via} to return an owner but got nil. " \
            "Ensure authentication is configured (e.g. before_action :authenticate_user!), " \
            "or pass `allow_nil: true` to opt into public-with-scope behaviour."
    end
    owner
  end

  define_singleton_method(:scope) do |controller|
    if pundit_enabled?
      controller.send(:policy_scope, model)
    else
      owner = resolve_owner.call(controller)
      owner.nil? ? model.none : model.where(association => owner)
    end
  end

  define_singleton_method(:build_record) do |controller|
    owner = resolve_owner.call(controller)
    base = pundit_enabled? ? model : scope(controller)
    base.new(association => owner)
  end
end

.per_page(value = nil) ⇒ Object



75
76
77
78
79
80
81
# File 'lib/layered/resource/base.rb', line 75

def per_page(value = nil)
  if value
    @per_page = value
  else
    inherited_attribute(:@per_page) || 15
  end
end

.permitted_paramsObject

Builds the args for params.permit(*permitted_params). Each field is permitted as a scalar by default. A permit: entry on a field opts that field into the hash form: permit: [] allows array values (e.g. documents: [] for has_many_attached), and permit: [:street, :city] allows a nested hash with those keys (e.g. address_attributes: [:street, :city] for accepts_nested).



189
190
191
192
193
194
195
196
197
# File 'lib/layered/resource/base.rb', line 189

def permitted_params
  fields.map do |f|
    if f.key?(:permit)
      { f[:attribute] => f[:permit] }
    else
      f[:attribute]
    end
  end
end

.pundit_enabled?Boolean

Returns:

  • (Boolean)


314
315
316
# File 'lib/layered/resource/base.rb', line 314

def pundit_enabled?
  inherited_attribute(:@use_pundit) == true
end

.record_label(record) ⇒ Object



125
126
127
# File 'lib/layered/resource/base.rb', line 125

def record_label(record)
  Layered::Resource.record_label(record, attribute: label_attribute)
end

.requires_distinct?Boolean

Returns:

  • (Boolean)


199
200
201
202
203
# File 'lib/layered/resource/base.rb', line 199

def requires_distinct?
  model.ransackable_associations(self).any? do |assoc|
    model.reflect_on_association(assoc)&.collection?
  end
end

.resolved_fieldsObject

The fields as the form layer wants them: each one's required: resolved from its validators unless declared, and permit: dropped. permit: is strong-parameters configuration read by permitted_params; the form helper passes any key it does not recognise through to the field's input, where a stray permit renders as an HTML attribute (on a select or text input) or raises (on a combobox, whose helper takes named options only).



98
99
100
101
102
103
104
105
# File 'lib/layered/resource/base.rb', line 98

def resolved_fields
  fields.map do |field|
    field = infer_association_field(field.except(:permit))
    next field if field.key?(:required)

    field.merge(required: attribute_required?(field[:attribute]))
  end
end

.resolved_filtersObject

Normalises filters into an array of control descriptors the view layer renders and patch_ransack allowlists. Each descriptor carries the Ransack attribute it keys on (ransack_attribute — the foreign key for association filters), the inferred control (as), and the collection/predicate metadata the control needs.



172
173
174
# File 'lib/layered/resource/base.rb', line 172

def resolved_filters
  normalize_filter_entries(filters).map { |attribute, opts| build_filter(attribute, opts) }
end

.root_breadcrumb(label = nil, path = nil) ⇒ Object

Declares a static crumb rendered before any derived breadcrumbs — typically a link back to the host app's dashboard:

root_breadcrumb "Home", "/"

Top-level resources otherwise have no trail at all; nested routes prepend this crumb to the derived parent trail.



59
60
61
62
63
64
65
# File 'lib/layered/resource/base.rb', line 59

def root_breadcrumb(label = nil, path = nil)
  if label
    @root_breadcrumb = { label: label, path: path }
  else
    inherited_attribute(:@root_breadcrumb)
  end
end

.scope(controller) ⇒ Object



235
236
237
238
239
240
241
# File 'lib/layered/resource/base.rb', line 235

def scope(controller)
  if pundit_enabled?
    controller.send(:policy_scope, model)
  else
    model.all
  end
end

.search_fields(value = nil) ⇒ Object



36
37
38
39
40
41
42
# File 'lib/layered/resource/base.rb', line 36

def search_fields(value = nil)
  if value
    @search_fields = value
  else
    inherited_attribute(:@search_fields) || []
  end
end

.search_placeholder(value = nil) ⇒ Object



44
45
46
47
48
49
50
# File 'lib/layered/resource/base.rb', line 44

def search_placeholder(value = nil)
  if value
    @search_placeholder = value
  else
    inherited_attribute(:@search_placeholder) || default_search_placeholder
  end
end

.use_punditObject

Opts the resource into Pundit. When enabled:

- `scope(controller)` is `Pundit.policy_scope(current_user, model)`.
- The controller calls `authorize(@record)` after loading a member
record (show/edit/update/destroy) — Pundit raises on denial.
- The `@resource_can_*` route-exposure flags are ANDed with the
class-level policy (e.g. `policy(model).new?`) so action buttons
hide automatically for users who can't perform the action.

Per-record visibility (e.g. "this user can edit this record") is available in views via the resource_can?(:update, record) helper, which composes the route-exposure flag with the per-record policy.



310
311
312
# File 'lib/layered/resource/base.rb', line 310

def use_pundit
  @use_pundit = true
end