Class: CrudComponents::Structure

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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_byObject (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_preloadsObject (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

#modelObject (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

#actionsObject

── 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 = permission ? permitted_search_spec(permission) : search_in_spec
    spec.any? ? LikeSpec.apply(scope, spec, query_string) : scope
  end
end

#association_field_namesObject

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| attachment_support_names.include?(n) }
end

#attachment_field_namesObject

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
  @attachment_field_names ||=
    model.respond_to?(:reflect_on_all_attachments) ? model.reflect_on_all_attachments.map(&:name) : []
end

#attachment_support_namesObject

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
  @attachment_support_names ||=
    attachment_field_names.flat_map do |att|
      %W[#{att}_attachment #{att}_attachments #{att}_blob #{att}_blobs].map(&:to_sym)
    end
end

#default_field_namesObject

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 + attachment_field_names + association_field_names
    base + (@declarations.keys - base)
  end
end

#default_fieldsetObject



116
117
118
# File 'lib/crud_components/structure.rb', line 116

def default_fieldset
  @default_fieldset ||= Fieldset.new(:default, :all)
end

#default_search_specObject



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

#iconObject

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_nameObject

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_sourceObject

── 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(permission)
  search_in_spec.reject do |entry|
    entry.is_a?(Symbol) && model.columns_hash.key?(entry.to_s) &&
      @declarations.key?(entry) && !field(entry).permitted?(permission)
  end
end

#search_in_specObject

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

Returns:

  • (Boolean)


197
198
199
# File 'lib/crud_components/structure.rb', line 197

def searchable?
  @search_decl.is_a?(Proc) || search_in_spec.any?
end