Class: CrudComponents::Structure
- Inherits:
-
Object
- Object
- CrudComponents::Structure
- Defined in:
- lib/crud_components/structure.rb
Overview
The resolved, validated description of how a model appears in the UI. Built lazily per model class and memoized; works for models without any declaration (rule zero: everything is derived from what Rails knows).
Constant Summary collapse
- RENDERER_GEMS =
{ markdown: %w[commonmarker redcarpet kramdown], asciidoc: %w[asciidoctor] }.freeze
Instance Attribute Summary collapse
-
#identify_by ⇒ Object
readonly
identity_preloads: associations to eager-load whenever this model is shown as another model’s association cell (its label/render dependencies), from ‘label …, preload:` and the standalone `preload` declaration.
-
#identity_preloads ⇒ Object
readonly
identity_preloads: associations to eager-load whenever this model is shown as another model’s association cell (its label/render dependencies), from ‘label …, preload:` and the standalone `preload` declaration.
-
#model ⇒ Object
readonly
identity_preloads: associations to eager-load whenever this model is shown as another model’s association cell (its label/render dependencies), from ‘label …, preload:` and the standalone `preload` declaration.
Class Method Summary collapse
Instance Method Summary collapse
- #action(name) ⇒ Object
-
#actions ⇒ Object
── actions ──────────────────────────────────────────────────────────────.
- #apply_search(scope, query_string, permission: nil) ⇒ Object
-
#association_field_names ⇒ Object
has_many / habtm / has_one — belongs_to already arrive via the FK swap.
-
#attachment_field_names ⇒ Object
Active Storage attachments (has_one_attached / has_many_attached), surfaced as image fields — derived, no declaration needed.
-
#attachment_support_names ⇒ Object
The join associations behind each attachment — excluded from the field universe, since the attachment itself is the field.
-
#default_field_names ⇒ Object
The :all set: every column (foreign keys swapped for their belongs_to), then Active Storage attachments, then non-belongs_to associations (has_many / habtm / has_one), then any declared computed fields — all derived, in a stable order.
- #default_fieldset ⇒ Object
- #default_search_spec ⇒ Object
-
#field(name) ⇒ Object
── fields ───────────────────────────────────────────────────────────────.
-
#fieldset(name = :default) ⇒ Object
── fieldsets ──────────────────────────────────────────────────────────── :default always exists; :index and :show fall back to :default when not declared; any other name must be declared (typo protection).
-
#fieldset_actions(fieldset, on:) ⇒ Object
A fieldset’s actions: list is authoritative per kind: listing only row actions curates the row buttons without losing the derived :new button (and vice versa).
- #fieldset_fields(fieldset) ⇒ Object
- #fieldset_filter_fields(fieldset) ⇒ Object
- #fieldset_sortable_fields(fieldset) ⇒ Object
-
#form_fieldset(action = nil) ⇒ Object
── forms ────────────────────────────────────────────────────────────── Form field selection falls back most-specific-first: the action’s own fieldset → :form → :default.
-
#icon ⇒ Object
The icon name (no library prefix) badging this model: the declared ‘icon`, else the name-based guess in `config.model_icons` (keyed by the singular underscored model name), else `config.model_fallback_icon` (nil = none).
-
#initialize(model, builder = nil) ⇒ Structure
constructor
A new instance of Structure.
-
#label_field_name ⇒ Object
The field whose cell carries the record link (nil for block labels).
- #label_for(record, context = nil) ⇒ Object
-
#label_source ⇒ Object
── identity ─────────────────────────────────────────────────────────────.
-
#permitted_params(action, context) ⇒ Object
Editable, permitted fields of the form fieldset, as a strong-params permit list (symbols and nested hashes) — the controller’s single source of truth, so form and params can never drift.
-
#permitted_search_spec(permission) ⇒ Object
A declared, permission-gated column (‘attribute :x, if: :manage`) is hidden everywhere including ?q= — so drop it from the search spec for a user who may not see it.
-
#search_in_spec ⇒ Object
── search ─────────────────────────────────────────────────────────────── nil when search_in is a custom block (delegation is then impossible).
- #searchable? ⇒ Boolean
Constructor Details
#initialize(model, builder = nil) ⇒ Structure
Returns a new instance of Structure.
47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/crud_components/structure.rb', line 47 def initialize(model, builder = nil) @model = model @declarations = builder&.declarations || {} @label_decl = builder&.label_decl @identify_by = builder&.identify_by_decl || :id @icon_decl = builder&.icon_decl @search_decl = builder&.search_decl @identity_preloads = ((builder&.label_preload_decl || []) + (builder&.preload_decl || [])).uniq @declared_actions = builder&.actions || {} @declared_fieldsets = builder&.fieldsets || {} @fields = {} validate! end |
Instance Attribute Details
#identify_by ⇒ Object (readonly)
identity_preloads: associations to eager-load whenever this model is shown as another model’s association cell (its label/render dependencies), from ‘label …, preload:` and the standalone `preload` declaration.
45 46 47 |
# File 'lib/crud_components/structure.rb', line 45 def identify_by @identify_by end |
#identity_preloads ⇒ Object (readonly)
identity_preloads: associations to eager-load whenever this model is shown as another model’s association cell (its label/render dependencies), from ‘label …, preload:` and the standalone `preload` declaration.
45 46 47 |
# File 'lib/crud_components/structure.rb', line 45 def identity_preloads @identity_preloads end |
#model ⇒ Object (readonly)
identity_preloads: associations to eager-load whenever this model is shown as another model’s association cell (its label/render dependencies), from ‘label …, preload:` and the standalone `preload` declaration.
45 46 47 |
# File 'lib/crud_components/structure.rb', line 45 def model @model end |
Class Method Details
.for(model) ⇒ Object
12 13 14 15 16 17 18 19 20 21 22 23 |
# File 'lib/crud_components/structure.rb', line 12 def for(model) unless model.respond_to?(:columns_hash) raise ArgumentError, "#{model.inspect} is not an ActiveRecord model class" end cached = model.instance_variable_get(:@_crud_structure) if model.instance_variable_defined?(:@_crud_structure) return cached if cached structure = new(model, find_builder(model)) model.instance_variable_set(:@_crud_structure, structure) structure end |
Instance Method Details
#action(name) ⇒ Object
232 233 234 235 |
# File 'lib/crud_components/structure.rb', line 232 def action(name) actions[name.to_sym] || raise(DefinitionError, "#{model} has no action :#{name} — " \ "available: #{actions.keys.map(&:inspect).join(', ')}") end |
#actions ⇒ Object
── actions ──────────────────────────────────────────────────────────────
222 223 224 225 226 227 228 229 230 |
# File 'lib/crud_components/structure.rb', line 222 def actions @actions ||= begin derived = %i[new show edit destroy].to_h { |name| [name, Action.new(name, derived: true)] } merged = derived.merge(@declared_actions) custom = @declared_actions.keys - derived.keys order = [:new, :show, :edit, *custom, :destroy] order.to_h { |name| [name, merged.fetch(name)] } end end |
#apply_search(scope, query_string, permission: nil) ⇒ Object
201 202 203 204 205 206 207 208 |
# File 'lib/crud_components/structure.rb', line 201 def apply_search(scope, query_string, permission: nil) if @search_decl.is_a?(Proc) @search_decl.call(scope.extending(WhereLike), query_string) else spec = ? permitted_search_spec() : search_in_spec spec.any? ? LikeSpec.apply(scope, spec, query_string) : scope end end |
#association_field_names ⇒ Object
has_many / habtm / has_one — belongs_to already arrive via the FK swap. Active Storage’s generated join associations (images_attachments/_blobs) and ActionText’s rich-text associations are not user-facing columns.
87 88 89 90 91 92 |
# File 'lib/crud_components/structure.rb', line 87 def association_field_names @association_field_names ||= model.reflect_on_all_associations.reject(&:belongs_to?).map(&:name) .reject { |n| n.to_s.start_with?('rich_text_', 'with_attached_') } .reject { |n| .include?(n) } end |
#attachment_field_names ⇒ Object
Active Storage attachments (has_one_attached / has_many_attached), surfaced as image fields — derived, no declaration needed.
79 80 81 82 |
# File 'lib/crud_components/structure.rb', line 79 def @attachment_field_names ||= model.respond_to?(:reflect_on_all_attachments) ? model..map(&:name) : [] end |
#attachment_support_names ⇒ Object
The join associations behind each attachment — excluded from the field universe, since the attachment itself is the field.
96 97 98 99 100 101 |
# File 'lib/crud_components/structure.rb', line 96 def @attachment_support_names ||= .flat_map do |att| %W[#{att}_attachment #{att}_attachments #{att}_blob #{att}_blobs].map(&:to_sym) end end |
#default_field_names ⇒ Object
The :all set: every column (foreign keys swapped for their belongs_to), then Active Storage attachments, then non-belongs_to associations (has_many / habtm / has_one), then any declared computed fields — all derived, in a stable order.
70 71 72 73 74 75 |
# File 'lib/crud_components/structure.rb', line 70 def default_field_names @default_field_names ||= begin base = column_field_names + + association_field_names base + (@declarations.keys - base) end end |
#default_fieldset ⇒ Object
116 117 118 |
# File 'lib/crud_components/structure.rb', line 116 def default_fieldset @default_fieldset ||= Fieldset.new(:default, :all) end |
#default_search_spec ⇒ Object
192 193 194 195 |
# File 'lib/crud_components/structure.rb', line 192 def default_search_spec model.columns.select { |col| %i[string text].include?(col.type) } .map { |col| col.name.to_sym } end |
#field(name) ⇒ Object
── fields ───────────────────────────────────────────────────────────────
62 63 64 |
# File 'lib/crud_components/structure.rb', line 62 def field(name) @fields[name.to_sym] ||= resolve_field(name.to_sym) end |
#fieldset(name = :default) ⇒ Object
── fieldsets ────────────────────────────────────────────────────────────:default always exists; :index and :show fall back to :default when not declared; any other name must be declared (typo protection).
106 107 108 109 110 111 112 113 114 |
# File 'lib/crud_components/structure.rb', line 106 def fieldset(name = :default) name = (name || :default).to_sym return @declared_fieldsets[name] if @declared_fieldsets.key?(name) return default_fieldset if %i[default index show].include?(name) known = (@declared_fieldsets.keys + [:default]).uniq raise UnknownFieldsetError, "#{model} has no fieldset :#{name} — " \ "available: #{known.map(&:inspect).join(', ')}" end |
#fieldset_actions(fieldset, on:) ⇒ Object
A fieldset’s actions: list is authoritative per kind: listing only row actions curates the row buttons without losing the derived :new button (and vice versa). An empty list hides everything.
240 241 242 243 244 245 246 247 248 |
# File 'lib/crud_components/structure.rb', line 240 def fieldset_actions(fieldset, on:) of_kind = ->(a) { a.public_send("#{on}?") } names = fieldset.action_names return actions.values.select(&of_kind) if names.nil? return [] if names.empty? listed = names.map { |name| action(name) }.select(&of_kind) listed.any? ? listed : actions.values.select(&of_kind) end |
#fieldset_fields(fieldset) ⇒ Object
120 121 122 123 |
# File 'lib/crud_components/structure.rb', line 120 def fieldset_fields(fieldset) names = fieldset.all_fields? ? default_field_names : fieldset.field_names names.map { |name| field(name) } end |
#fieldset_filter_fields(fieldset) ⇒ Object
125 126 127 128 |
# File 'lib/crud_components/structure.rb', line 125 def fieldset_filter_fields(fieldset) (fieldset_fields(fieldset) + fieldset.filter_names.map { |name| field(name) }) .uniq.select(&:filterable?) end |
#fieldset_sortable_fields(fieldset) ⇒ Object
130 131 132 |
# File 'lib/crud_components/structure.rb', line 130 def fieldset_sortable_fields(fieldset) fieldset_fields(fieldset).select(&:sortable?) end |
#form_fieldset(action = nil) ⇒ Object
── forms ──────────────────────────────────────────────────────────────Form field selection falls back most-specific-first:
the action's own fieldset → :form → :default.
137 138 139 140 141 142 143 |
# File 'lib/crud_components/structure.rb', line 137 def form_fieldset(action = nil) names = [action, :form, :default].compact names.each do |name| return @declared_fieldsets[name] if @declared_fieldsets.key?(name) end default_fieldset end |
#icon ⇒ Object
The icon name (no library prefix) badging this model: the declared ‘icon`, else the name-based guess in `config.model_icons` (keyed by the singular underscored model name), else `config.model_fallback_icon` (nil = none). Resolved per call so a host’s config changes apply without rebuilding.
179 180 181 182 |
# File 'lib/crud_components/structure.rb', line 179 def icon @icon_decl || CrudComponents.config.model_icons[model.model_name.element] || CrudComponents.config.model_fallback_icon end |
#label_field_name ⇒ Object
The field whose cell carries the record link (nil for block labels).
171 172 173 |
# File 'lib/crud_components/structure.rb', line 171 def label_field_name label_source.is_a?(Symbol) ? label_source : nil end |
#label_for(record, context = nil) ⇒ Object
162 163 164 165 166 167 168 |
# File 'lib/crud_components/structure.rb', line 162 def label_for(record, context = nil) case (source = label_source) when Proc then context ? context.instance_exec(record, &source) : source.call(record) when Symbol then record.public_send(source) else "#{model.model_name.human} ##{record.id}" end end |
#label_source ⇒ Object
── identity ─────────────────────────────────────────────────────────────
155 156 157 158 159 160 |
# File 'lib/crud_components/structure.rb', line 155 def label_source return @label_decl if @label_decl @label_source ||= %i[name title].find { |attr| model.columns_hash.key?(attr.to_s) } || model.columns.find { |col| col.type == :string }&.name&.to_sym end |
#permitted_params(action, context) ⇒ Object
Editable, permitted fields of the form fieldset, as a strong-params permit list (symbols and nested hashes) — the controller’s single source of truth, so form and params can never drift.
148 149 150 151 152 |
# File 'lib/crud_components/structure.rb', line 148 def permitted_params(action, context) fields = fieldset_fields(form_fieldset(action)) fields.select { |f| f.permitted?(context) && f.editable? && f.editable_permitted?(context) && f.form_control } .map(&:permit_param) end |
#permitted_search_spec(permission) ⇒ Object
A declared, permission-gated column (‘attribute :x, if: :manage`) is hidden everywhere including ?q= — so drop it from the search spec for a user who may not see it. Undeclared columns in the default spec are model-global search by design and stay.
214 215 216 217 218 219 |
# File 'lib/crud_components/structure.rb', line 214 def permitted_search_spec() search_in_spec.reject do |entry| entry.is_a?(Symbol) && model.columns_hash.key?(entry.to_s) && @declarations.key?(entry) && !field(entry).permitted?() end end |
#search_in_spec ⇒ Object
── search ───────────────────────────────────────────────────────────────nil when search_in is a custom block (delegation is then impossible).
186 187 188 189 190 |
# File 'lib/crud_components/structure.rb', line 186 def search_in_spec return nil if @search_decl.is_a?(Proc) @search_in_spec ||= (@search_decl.presence || default_search_spec) end |
#searchable? ⇒ Boolean
197 198 199 |
# File 'lib/crud_components/structure.rb', line 197 def searchable? @search_decl.is_a?(Proc) || search_in_spec.any? end |