Class: Layered::Resource::Base
- Inherits:
-
Object
- Object
- Layered::Resource::Base
- 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/_eqpredicates, 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_comboboxoptions 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
- .after_save_path(controller, _record) ⇒ Object
-
.association_search_fields ⇒ Object
Resolves
search_fieldsentries that aren't columns on the resource's own model but match Ransack's association-walk form<association>_<attribute>(e.g.:user_namesearchesusers.namefrom abelongs_to :user). - .build_record(controller) ⇒ Object
- .columns(value = nil) ⇒ Object
- .configure_ransack ⇒ Object
- .default_sort(value = nil) ⇒ Object
- .field_type_for(attribute) ⇒ Object
- .fields(value = nil) ⇒ Object
-
.filter_attributes ⇒ Object
The own-model column names a filter set needs allowlisted for Ransack (e.g.
status,created_at,user_id). -
.filters(*entries) ⇒ Object
Declares structured filter controls for the index table.
-
.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.
- .model(klass = nil) ⇒ Object
-
.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).
- .per_page(value = nil) ⇒ Object
-
.permitted_params ⇒ Object
Builds the args for
params.permit(*permitted_params). - .pundit_enabled? ⇒ Boolean
- .record_label(record) ⇒ Object
- .requires_distinct? ⇒ Boolean
-
.resolved_fields ⇒ Object
The fields as the form layer wants them: each one's
required:resolved from its validators unless declared, andpermit:dropped. -
.resolved_filters ⇒ Object
Normalises
filtersinto an array of control descriptors the view layer renders andpatch_ransackallowlists. -
.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:.
- .scope(controller) ⇒ Object
- .search_fields(value = nil) ⇒ Object
- .search_placeholder(value = nil) ⇒ Object
-
.use_pundit ⇒ Object
Opts the resource into Pundit.
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_fields ⇒ Object
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_ransack ⇒ Object
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_attributes ⇒ Object
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_params ⇒ Object
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
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
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_fields ⇒ Object
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_filters ⇒ Object
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:
"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 (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_pundit ⇒ Object
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 |